Skip to content

Commit

Permalink
Merge pull request #4354 from serlo/feat/bildungsraum-share-copy-edit…
Browse files Browse the repository at this point in the history
…or-content

feat(bildungsraum-share): copy content to clipboard
  • Loading branch information
hejtful authored Dec 19, 2024
2 parents f39bef3 + 66a7333 commit 33ecaad
Show file tree
Hide file tree
Showing 15 changed files with 325 additions and 78 deletions.
2 changes: 1 addition & 1 deletion apps/web/src/components/pages/user/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const Profile: NextPage<ProfileProps> = ({ userData }) => {
<ProfileBadges userData={userData} date={date} />
</div>
<div className="serlo-p mt-5 w-full text-1.5xl [grid-area:motivation] sm:mt-0">
{motivation && <>&quot;{motivation}&quot;</>}
{motivation && <>&bdquo;{motivation}&ldquo;</>}
{isOwnProfile &&
!isNewlyRegisteredUser &&
renderEditMotivationLink()}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/user-tools/share/share-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
faCopy,
faDownload,
faEnvelope,
faFileText,
} from '@fortawesome/free-solid-svg-icons'
import { QRCodeSVG } from 'qrcode.react'
import { MouseEvent, useState, useEffect } from 'react'
Expand All @@ -20,10 +21,12 @@ import { Instance } from '@/fetcher/graphql-types/operations'
import { cn } from '@/helper/cn'
import { colors } from '@/helper/colors'
import { showToastNotice } from '@/helper/show-toast-notice'
import { serloDomain } from '@/helper/urls/serlo-domain'

export interface ShareModalProps {
isOpen: boolean
setIsOpen: (open: boolean) => void
showCopyContent?: boolean
showPdf?: boolean
path?: string
}
Expand All @@ -36,9 +39,15 @@ interface EntryData {
onClick?: (event: MouseEvent) => void
}

const base =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://' + serloDomain

export function ShareModal({
isOpen,
setIsOpen,
showCopyContent,
showPdf,
path,
}: ShareModalProps) {
Expand Down Expand Up @@ -70,10 +79,41 @@ export function ShareModal({
}
}

async function copyContentToClipboard() {
if (!pathOrId) return
try {
const url = `${base}/api/frontend/bildungsraum-share?href=${encodeURIComponent(pathOrId)}`
const res = await fetch(url)
const data = (await res.json()) as string
if (!res.ok) {
throw new Error(
'injection-content API call failed with error: ' + data.toString()
)
}
await navigator.clipboard.writeText(JSON.stringify(data))
showToastNotice('👌 Erfolgreich kopiert', 'success')
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
showToastNotice(
'❌ Leider gab es ein Problem beim kopieren. Tut uns leid.',
'warning'
)
}
}

const shareUrl = `${window.location.protocol}//${window.location.host}/${pathOrId}`
const urlEncoded = encodeURIComponent(shareUrl)
const titleEncoded = encodeURIComponent(document.title)

const contentCopy = [
{
title: 'Inhalt kopieren',
icon: faFileText,
onClick: () => copyContentToClipboard(),
},
]

const socialShare = [
{
title: 'E-Mail',
Expand Down Expand Up @@ -156,6 +196,24 @@ export function ShareModal({
{renderButtons(pdfData)}
</>
)}

{showCopyContent ? ( // "de" only
<>
<hr className="mx-side my-4" />
<h3 className="serlo-h3 my-4">Inhalt zum Bearbeiten kopieren</h3>
<p className="serlo-p mb-0 text-base">
Du kannst diesen Inhalt in jedem Serlo Editor weiterbearbeiten: Hier
auf <b>serlo.org</b> und in LMS wie Moodle, Edu-sharing oder
itslearning, die den Serlo Editor eingebaut haben.
<br />
<br />
Dazu einfach auf unten auf &bdquo;Inhalt kopieren&ldquo; klicken,
einen Moment warten und dann Inhalt im Editor Textfeld Deines LMS
einfügen.
</p>
{renderButtons(contentCopy)}
</>
) : null}
</ModalWithCloseButton>
)

Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/user-tools/share/share.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const ShareModal = dynamic<ShareModalProps>(() =>
)

export function Share({ data, aboveContent }: MoreAuthorToolsProps) {
const { strings } = useInstanceData()
const { lang, strings } = useInstanceData()
const [shareOpen, setShareOpen] = useState(false)

const showPdf =
Expand All @@ -28,6 +28,13 @@ export function Share({ data, aboveContent }: MoreAuthorToolsProps) {
UuidType.Exercise,
].includes(data.typename as UuidType)

const showCopyContent =
lang === 'de' &&
data &&
[UuidType.Article, UuidType.ExerciseGroup, UuidType.Exercise].includes(
data.typename as UuidType
)

return (
<>
<UserToolsItem
Expand All @@ -40,6 +47,7 @@ export function Share({ data, aboveContent }: MoreAuthorToolsProps) {
<ShareModal
isOpen={shareOpen}
setIsOpen={setShareOpen}
showCopyContent={showCopyContent}
showPdf={showPdf}
/>
) : null}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/user/event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function Event({
),
comment: (
<p className="font-normal">
&quot;{event.thread.thread.nodes[0].content}&quot;
&bdquo;{event.thread.thread.nodes[0].content}&ldquo;
</p>
),
})
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/data/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export const instanceData = {
'The provided authentication code is invalid, please try again.',
code4000010:
'Have you already verified your email address?.%break% %verificationLinkText%',
code4000032: "You inserted less than 8 characters.",
code4000032: 'You inserted less than 8 characters.',
code4060004:
'The recovery link is not valid or has already been used. Please try requesting an email again',
code4070001:
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/fetcher/graphql-types/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,13 @@ type GetCommentsThreadsOldComments_VideoRevision_Fragment = { __typename?: 'Vide

export type GetCommentsThreadsOldCommentsFragment = GetCommentsThreadsOldComments_Applet_Fragment | GetCommentsThreadsOldComments_AppletRevision_Fragment | GetCommentsThreadsOldComments_Article_Fragment | GetCommentsThreadsOldComments_ArticleRevision_Fragment | GetCommentsThreadsOldComments_Course_Fragment | GetCommentsThreadsOldComments_CoursePage_Fragment | GetCommentsThreadsOldComments_CoursePageRevision_Fragment | GetCommentsThreadsOldComments_CourseRevision_Fragment | GetCommentsThreadsOldComments_Event_Fragment | GetCommentsThreadsOldComments_EventRevision_Fragment | GetCommentsThreadsOldComments_Exercise_Fragment | GetCommentsThreadsOldComments_ExerciseGroup_Fragment | GetCommentsThreadsOldComments_ExerciseGroupRevision_Fragment | GetCommentsThreadsOldComments_ExerciseRevision_Fragment | GetCommentsThreadsOldComments_Page_Fragment | GetCommentsThreadsOldComments_PageRevision_Fragment | GetCommentsThreadsOldComments_TaxonomyTerm_Fragment | GetCommentsThreadsOldComments_User_Fragment | GetCommentsThreadsOldComments_Video_Fragment | GetCommentsThreadsOldComments_VideoRevision_Fragment;

export type ShareEditorContentQueryVariables = Exact<{
path: Scalars['String']['input'];
}>;


export type ShareEditorContentQuery = { __typename?: 'Query', uuid?: { __typename: 'Applet', currentRevision?: { __typename?: 'AppletRevision', content: string } | null } | { __typename: 'AppletRevision' } | { __typename: 'Article', currentRevision?: { __typename?: 'ArticleRevision', content: string } | null } | { __typename: 'ArticleRevision' } | { __typename: 'Comment' } | { __typename: 'Course', currentRevision?: { __typename?: 'CourseRevision', content: string } | null } | { __typename: 'CoursePage', currentRevision?: { __typename?: 'CoursePageRevision', content: string } | null } | { __typename: 'CoursePageRevision' } | { __typename: 'CourseRevision' } | { __typename: 'Event', currentRevision?: { __typename?: 'EventRevision', content: string } | null } | { __typename: 'EventRevision' } | { __typename: 'Exercise', currentRevision?: { __typename?: 'ExerciseRevision', content: string } | null } | { __typename: 'ExerciseGroup', currentRevision?: { __typename?: 'ExerciseGroupRevision', content: string } | null } | { __typename: 'ExerciseGroupRevision' } | { __typename: 'ExerciseRevision' } | { __typename: 'Page', currentRevision?: { __typename?: 'PageRevision', content: string } | null } | { __typename: 'PageRevision' } | { __typename: 'TaxonomyTerm' } | { __typename: 'User' } | { __typename: 'Video', currentRevision?: { __typename?: 'VideoRevision', content: string } | null } | { __typename: 'VideoRevision' } | null };

export type InjectionOnlyContentQueryVariables = Exact<{
path: Scalars['String']['input'];
}>;
Expand Down
101 changes: 101 additions & 0 deletions apps/web/src/pages/api/frontend/bildungsraum-share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { parseDocumentString } from '@editor/static-renderer/helper/parse-document-string'
import type {
EditorArticleDocument,
EditorExerciseDocument,
EditorExerciseGroupDocument,
} from '@editor/types/editor-plugins'
import { gql } from 'graphql-request'
import type { NextApiRequest, NextApiResponse } from 'next'

import { endpoint } from '@/api/endpoint'
import { InjectionOnlyContentQuery } from '@/fetcher/graphql-types/operations'
import { isProduction } from '@/helper/is-production'

/**
* Allows frontend to copy Serlo content to the clipboard.
* The content is unpacked for consistent pasting in the Editor.
*/
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const href = decodeURIComponent(String(req.query.href))

const [base] = href.split('#')
const path = base.startsWith('/') ? base : `/${base}`

if (!path) {
return res.status(401).json('no path provided')
}

try {
void fetch(endpoint, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({ query, variables: { path } }),
})
.then((res) => res.json())
.then((data: { data: InjectionOnlyContentQuery }) => {
if (!data.data?.uuid) {
return res.status(404).json('not found')
}

const uuid = data.data.uuid

if (!Object.hasOwn(uuid, 'currentRevision') || !uuid.currentRevision) {
return res.status(404).json('no current revision')
}

if (uuid.__typename === 'Article') {
const articleDocument = parseDocumentString(
uuid.currentRevision.content
) as EditorArticleDocument
const articleContent = articleDocument.state.content
respondWithContent(articleContent)
return
}

if (
uuid.__typename === 'Exercise' ||
uuid.__typename === 'ExerciseGroup'
) {
const exercise = parseDocumentString(uuid.currentRevision.content) as
| EditorExerciseDocument
| EditorExerciseGroupDocument
respondWithContent({ plugin: 'rows', state: [exercise] })
return
}

return res.status(422).json('unsupported entity type')
})
.catch((e) => {
return res.status(500).json(`${String(e)} at ${path}`)
})
} catch (e) {
return res.status(500).json(`${String(e)} at ${path}`)
}

function respondWithContent(content: any) {
const twoDaysInSeconds = 172800
res.setHeader('Cache-Control', `maxage=${twoDaysInSeconds}`)
if (!isProduction) res.setHeader('Access-Control-Allow-Origin', '*')
res.status(200).json(content)
}
}

const query = gql`
query shareEditorContent($path: String!) {
uuid(alias: { path: $path, instance: de }) {
__typename
... on AbstractEntity {
currentRevision {
content
}
}
}
}
`
4 changes: 2 additions & 2 deletions apps/web/src/serlo-editor-integration/h5p/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function H5pEditor({ state }: H5pProps) {
Registriere dich mit deiner E-Mail-Adresse und melde dich an.
</li>
<li>
Klicke auf &quot;Neuen Inhalt erstellen&quot; und wähle eines
Klicke auf &bdquo;Neuen Inhalt erstellen&ldquo; und wähle eines
der folgenden Inhaltstypen:
<ul className="serlo-ul">
{Object.values(availableH5pExercises).map((exercise) => (
Expand All @@ -140,7 +140,7 @@ function H5pEditor({ state }: H5pProps) {
</li>
<li>
Erstelle deinen Inhalt, speichere ihn und klicke dann auf
&quot;Inhalt bereitstellen&quot;.
&bdquo;Inhalt bereitstellen&ldquo;.
</li>
<li>Füge die Verknüpfung zur Bereitstellung hier ein:</li>
</ul>
Expand Down
4 changes: 4 additions & 0 deletions packages/editor/src/i18n/strings/de/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ export const editStrings = {
'Sorry, Elemente einfügen klappt nicht in Listen.',
pastingPluginNotAllowedHere:
'Sorry, dieses Plugin kannst du hier nicht einfügen.',
unsupportedPluginsPasted:
'Ein paar der Plugins die du eingefügt hast, werden hier nicht unterstützt.',
invalidDataPasted:
'Sorry, mit den Daten die du einfügen willst stimmt etwas nicht. Das liegt wahrscheinlich an uns.',
linkOverlay: {
placeholder: 'Suchbegriff oder "/1234"',
placeholderNonSerlo: 'Link',
Expand Down
3 changes: 3 additions & 0 deletions packages/editor/src/i18n/strings/en/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ export const editStrings = {
'Sorry, pasting elements inside of lists is not allowed.',
pastingPluginNotAllowedHere:
'Sorry, pasting this plugin here is not allowed.',
unsupportedPluginsPasted:
'There were unsupported plugins in the data you pasted.',
invalidDataPasted: 'Sorry, something is wrong with the data you pasted.',
linkOverlay: {
placeholder: 'https://… or /1234',
placeholderNonSerlo: 'https://',
Expand Down
10 changes: 7 additions & 3 deletions packages/editor/src/plugin/helpers/editor-plugins.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EditorPlugin } from '../internal-plugin'
import { emitUnsupportedPluginsEvent } from './unsupported-plugin-event'

export interface PluginWithData {
type: string
Expand Down Expand Up @@ -28,9 +29,12 @@ export const editorPlugins = (function () {
function getByType(pluginType: string) {
const plugins = getAllWithData()

const contextPlugin =
plugins.find((plugin) => plugin.type === pluginType) ??
plugins.find((plugin) => plugin.type === 'unsupported')
let contextPlugin = plugins.find((plugin) => plugin.type === pluginType)

if (contextPlugin === undefined) {
emitUnsupportedPluginsEvent()
contextPlugin = plugins.find((plugin) => plugin.type === 'unsupported')
}

return (contextPlugin?.plugin as EditorPlugin) ?? null
}
Expand Down
15 changes: 15 additions & 0 deletions packages/editor/src/plugin/helpers/unsupported-plugin-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const eventName = 'unsupportedPluginType'

const event = new Event(eventName)

export function emitUnsupportedPluginsEvent() {
document.dispatchEvent(event)
}

export function listenForUnsupportedPlugins(callback: () => void) {
document.addEventListener(eventName, callback, { capture: true, once: true })
}

export function removeUnsupportedPluginsListener(callback: () => void) {
document.removeEventListener(eventName, callback, true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Editor as SlateEditor, Range, Node, Transforms } from 'slate'
import { useTextConfig } from './use-text-config'
import type { TextEditorProps } from '../components/text-editor'
import { emptyDocumentFactory, mergePlugins } from '../utils/document'
import { insertPlugin } from '../utils/insert-plugin'
import { insertPlugins } from '../utils/insert-plugins'
import { instanceStateStore } from '../utils/instance-state-store'
import { isSelectionAtEnd, isSelectionAtStart } from '../utils/selection'

Expand Down Expand Up @@ -74,13 +74,14 @@ export const useEditableKeydownHandler = (
payload: {
insertIndex,
insertCallback: (plugin) => {
insertPlugin({
pluginType: plugin.plugin,
insertPlugins({
plugins: [
{ pluginType: plugin.plugin, state: plugin.state },
],
editor,
id,
dispatch,
state: plugin.state,
getStoreState: () => store.getState(),
dispatch,
})
},
},
Expand Down
Loading

0 comments on commit 33ecaad

Please sign in to comment.