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

WIP: New plugin for integrating media items from Datenraum #3806

Closed
wants to merge 10 commits into from
106 changes: 106 additions & 0 deletions apps/web/src/pages/api/experimental/search-datenraum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as t from 'io-ts'
import type { NextApiRequest, NextApiResponse } from 'next'

import { isProduction } from '@/helper/is-production'

const AccessTokenResponse = t.type({
access_token: t.string,
})

const SearchResponse = t.type({
_embedded: t.type({
nodes: t.array(t.unknown),
}),
})

const SearchNode = t.type({
title: t.string,
description: t.string,
metadata: t.type({ Amb: t.type({ id: t.string }) }),
})

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (isProduction) {
res.status(404).end()
return
}

const cliendId = process.env.DATENRAUM_CLIENT_ID
const clientSecret = process.env.DATENRAUM_CLIENT_SECRET

if (!cliendId || !clientSecret) {
res.status(500).json({ message: 'Datenraum credentials not set' })
return
}

const { q: query } = req.query

if (!query || Array.isArray(query)) {
res.status(400).json({
message: 'Query parameter missing or multiple parameter are passed to it',
})
return
}

const accessTokenResponse = await fetch(
'https://aai.demo.meinbildungsraum.de/realms/nbp-aai/protocol/openid-connect/token',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${cliendId}:${clientSecret}`)}`,
},
body: 'grant_type=client_credentials',
}
)

if (!accessTokenResponse.ok) {
res.status(500).json({ message: 'Failed to get access token' })
return
}

const accessTokenResponseJson = (await accessTokenResponse.json()) as unknown

if (!AccessTokenResponse.is(accessTokenResponseJson)) {
res.status(500).json({ message: 'Access token missing' })
return
}

const { access_token: accessToken } = accessTokenResponseJson

const searchResponse = await fetch(
`https://dam.demo.meinbildungsraum.de/datenraum/api/core/nodes?search=${encodeURIComponent(query)}&offset=0&limit=30`,
{
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
)

const searchResults = (await searchResponse.json()) as unknown

if (!SearchResponse.is(searchResults)) {
res.status(500).json({ message: 'Failed to get search results' })
return
}

const nodes = searchResults._embedded.nodes.filter(SearchNode.is)

res.json(
nodes.map((node) => ({
title: node.title,
description: node.description,
url: node.metadata.Amb.id,
}))
)
}

export const config = {
api: {
externalResolver: true,
},
}
10 changes: 10 additions & 0 deletions apps/web/src/serlo-editor-integration/create-plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { articlePlugin } from '@editor/plugins/article'
import { audioPlugin } from '@editor/plugins/audio'
import { blanksExercise } from '@editor/plugins/blanks-exercise'
import { createBoxPlugin } from '@editor/plugins/box'
import { datenraumIntegrationPlugin } from '@editor/plugins/datenraum-integration'
import { equationsPlugin } from '@editor/plugins/equations'
import { exercisePlugin } from '@editor/plugins/exercise'
import { exerciseGroupPlugin } from '@editor/plugins/exercise-group'
Expand Down Expand Up @@ -142,6 +143,15 @@ export function createPlugins({
icon: <IconAudio />,
},
]),
...(isProduction
? []
: [
{
type: EditorPluginType.DatenraumIntegration,
plugin: datenraumIntegrationPlugin,
visibleInSuggestions: true,
},
]),
{
type: EditorPluginType.Anchor,
plugin: anchorPlugin,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/serlo-editor-integration/create-renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { AnchorStaticRenderer } from '@editor/plugins/anchor/static'
import { ArticleStaticRenderer } from '@editor/plugins/article/static'
import { BoxStaticRenderer } from '@editor/plugins/box/static'
import { DatenraumIntegrationDocument } from '@editor/plugins/datenraum-integration/static'
import { RowsStaticRenderer } from '@editor/plugins/rows/static'
import type { MathElement } from '@editor/plugins/text'
import { TextStaticRenderer } from '@editor/plugins/text/static'
Expand Down Expand Up @@ -38,6 +39,12 @@ import { Link } from '@/components/content/link'
import { isPrintMode } from '@/components/print-mode'
import { MultimediaSerloStaticRenderer } from '@/serlo-editor-integration/serlo-plugin-wrappers/multimedia-serlo-static-renderer'

const DatenraumIntegrationStaticRenderer =
dynamic<DatenraumIntegrationDocument>(() =>
import('@editor/plugins/datenraum-integration/static').then(
(mod) => mod.DatenraumIntegrationStaticRenderer
)
)
const EquationsStaticRenderer = dynamic<EditorEquationsDocument>(() =>
import('@editor/plugins/equations/static').then(
(mod) => mod.EquationsStaticRenderer
Expand Down Expand Up @@ -232,6 +239,11 @@ export function createRenderers(): InitRenderersArgs {
return null
},
},
// experimental plugins
{
type: EditorPluginType.DatenraumIntegration,
renderer: DatenraumIntegrationStaticRenderer,
},
],
mathRenderer: (element: MathElement) =>
element.inline ? (
Expand Down
4 changes: 2 additions & 2 deletions packages/editor/src/package/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
type PluginsConfig,
defaultSerloEditorProps,
type CustomPlugin,
} from './config.js'
import { editorData } from './editor-data.js'
} from './config'
import { editorData } from './editor-data'
import { InstanceDataProvider } from '@/contexts/instance-context'
import { LoggedInDataProvider } from '@/contexts/logged-in-data-context'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function Icon({ url }: { url: string }) {
if (url.includes('serlo')) {
return (
<img
src="https://de.serlo.org/_assets/apple-touch-icon.png"
alt="Serlo"
className="h-8 w-8"
/>
)
} else if (url.includes('vhs')) {
return (
<img
src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Deutscher_Volkshochschul-Verband%2C_VHS-Logo_-_Logo_of_the_German_adult_education_centre_association.png/320px-Deutscher_Volkshochschul-Verband%2C_VHS-Logo_-_Logo_of_the_German_adult_education_centre_association.png"
alt="Serlo"
className="h-8 w-8"
/>
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { H5pRenderer } from '@editor/plugins/h5p/renderer'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useState } from 'react'

interface SearchPanelProps {
onSelect: () => void
}

export function SearchPanel({ onSelect }: SearchPanelProps) {
const [query, setQuery] = useState('')
const [showResults, setShowResults] = useState<boolean>(false)
const [loading, setLoading] = useState(false)

const handleSearch = () => {
if (!loading) {
setLoading(true)
setTimeout(() => {
setLoading(false)
setShowResults(true)
}, 500)
}
}

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
void handleSearch()
}
}

return (
<div className="w-full">
<div className="relative w-full">
<input
type="text"
className="block w-full rounded-md border-2 border-gray-300 py-2 pl-4 pr-10 focus:border-indigo-500 focus:outline-none"
placeholder="Suchbergriff eingeben..."
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="absolute inset-y-0 right-0 rounded-md bg-indigo-500 px-4 text-white"
onClick={handleSearch}
>
Suchen
</button>
</div>

{loading && <FontAwesomeIcon icon={faSpinner} spin />}

{showResults && (
<div onClick={onSelect} className="cursor-pointer">
<div className="pointer-events-none">
<H5pRenderer url="https://app.lumi.education/run/J3j0eR" />
</div>
</div>
)}
</div>
)
}
77 changes: 77 additions & 0 deletions packages/editor/src/plugins/datenraum-integration/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export const hardcodedExerciseState = {
content: {
plugin: 'rows',
state: [
{
plugin: 'text',
state: [
{
type: 'p',
children: [{ text: 'Trage die fehlenden Wörter ein!' }],
},
],
},
],
},
interactive: {
plugin: 'blanksExercise',
state: {
text: {
plugin: 'text',
state: [
{
type: 'p',
children: [
{ text: 'Ein ' },
{
type: 'textBlank',
blankId: 'ae9a2036-c58e-4bc8-89fd-54beb4222fa1',
correctAnswers: [{ answer: 'Riesenrad' }],
acceptMathEquivalents: true,
children: [{ text: '' }],
},
{
text: ' ist groß und rund, man findet es häufig auf großen Volksfesten. Es hat viele Gondeln.',
},
],
},
{ type: 'p', children: [{ text: '' }] },
{
type: 'p',
children: [
{ text: 'Ein ' },
{
type: 'textBlank',
blankId: '573872fa-9332-4d5f-9cce-28962d050025',
correctAnswers: [{ answer: 'Zug' }],
acceptMathEquivalents: true,
children: [{ text: '' }],
},
{ text: ' fährt auf Schienen.' },
],
},
{ type: 'p', children: [{ text: '' }] },
{
type: 'p',
children: [
{ text: 'Mit einem ' },
{
type: 'textBlank',
blankId: '6b1cb8b7-e61a-4f05-9bb3-9339aa523cf0',
correctAnswers: [
{ answer: 'Computer' },
{ answer: 'PC' },
{ answer: 'Laptop' },
],
acceptMathEquivalents: true,
children: [{ text: '' }],
},
{ text: ' arbeiten EntwicklerInnen.' },
],
},
],
},
mode: 'typing',
},
},
}
Loading