Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bildungsraum-share): copy content to clipboard #4354

Merged
merged 21 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b99e26b
feat(bildungsraum-share): add endpoint + enable article copy + adjust…
hejtful Dec 15, 2024
641e437
feat(bildungsraum-share): add rows plugin decoding to paste handler
hejtful Dec 16, 2024
5803c8a
feat(bildungsraum-share): simplify user feedback
hejtful Dec 16, 2024
19c672d
fix(bildungsraum-share): remove course from decoder
hejtful Dec 16, 2024
8a4ae41
feat(bildungsraum-share): add Exercise content type to endpoint
hejtful Dec 16, 2024
cd263f7
feat(bildungsraum-share): add ExerciseGroup content type to endpoint
hejtful Dec 16, 2024
4a07272
feat(bildungsraum-share): enable ony for Article, Exercise, ExerciseG…
hejtful Dec 16, 2024
524c350
fix(bildungsraum-share): remove unnecessary import
hejtful Dec 16, 2024
3fdf3d1
fix(bildungsraum-share): try to fix copy button in preview
hejtful Dec 16, 2024
51865e8
fix(bildungsraum-share): add Rows plugin to paste decoder + improve v…
hejtful Dec 16, 2024
6d0b9ad
fix(bildungsraum-share): lax decoding + snack for unsupported plugins
hejtful Dec 18, 2024
29d210f
chore(bildungsraum-share): revert unnecessary change in paste handler
hejtful Dec 18, 2024
81cfa1f
fix(share-modal): add german strings
elbotho Dec 18, 2024
fb2c704
feat(copy content button): make "de" only and add some explanation
elbotho Dec 18, 2024
debc6de
Update apps/web/src/components/user-tools/share/share-modal.tsx
hejtful Dec 18, 2024
ad41c18
Update apps/web/src/pages/api/frontend/bildungsraum-share.ts
hejtful Dec 18, 2024
5f14475
fix(bildungsraum-share): query and fallback response
hejtful Dec 18, 2024
7b96719
fix(bildungsraum-share): simplify domain getter
hejtful Dec 19, 2024
9ea1800
fix(editor): update text
elbotho Dec 19, 2024
b603da5
chore: use german quotes
elbotho Dec 19, 2024
66a7333
Merge pull request #4362 from serlo/share-copy-button-add-explanation
hejtful Dec 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading