feat(cms): Tiptap rich-text editor, slide editor, AI generator, WebDAV picker

Completes the CMS port to feature parity with public/js/learningHub.js
(@be14578). Previously the React CMS had only a plain-textarea body,
no slide editor for presentations, and no AI content generation —
three large gaps from the vanilla app.

New components:
  client/src/components/RichTextEditor.tsx — Tiptap/ProseMirror editor
    with the vanilla tp-toolbar feature set: bold/italic/underline/
    strike, H2/H3, bullet/ordered/quote/codeblock lists, link-with-
    URL-bar, and clear formatting. Three variants (default/mini/
    option) match vanilla's buildTpToolbar(mini, isOption).

  client/src/pages/cms/SlideEditor.tsx — per-slide Tiptap editor +
    slide navigator (move/add/remove). Slides join back to the body
    column with the vanilla \n---\n separator so the Learning Hub
    viewer renders them unchanged.

  client/src/pages/cms/AiGenerator.tsx — generate content via three
    sources (topic / upload / Nextcloud WebDAV). POSTs multipart
    to /api/admin/learning/ai-generate, matching the vanilla
    runAiGenerate flow exactly. WebDAV file browser uses the
    existing /api/admin/learning/webdav-browse endpoint. Auto-
    hides the Nextcloud tab when the user isn't connected.

Updated:
  QuestionsEditor — question text, option text, per-option
    explanation, and question explanation are now all Tiptap-backed
    so quiz authoring matches content authoring in feel.

  ContentEditor — "Generate with AI" button opens the AI panel;
    presentation body uses SlideEditor; everything else uses
    RichTextEditor with a 320px min height.

Dependencies added:
  @tiptap/react @tiptap/pm @tiptap/starter-kit
  @tiptap/extension-link @tiptap/extension-underline
This commit is contained in:
Daniel 2026-04-24 05:16:34 +02:00
parent 5342fd1205
commit b2ef470180
7 changed files with 1332 additions and 41 deletions

594
client/package-lock.json generated
View file

@ -10,6 +10,11 @@
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.1",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.9.0",
@ -33,6 +38,9 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@babel/code-frame": {
@ -434,6 +442,31 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"optional": true
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@ -1104,6 +1137,406 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.4.tgz",
"integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.4.tgz",
"integrity": "sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.4.tgz",
"integrity": "sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.4.tgz",
"integrity": "sha512-v4pux5Ql3THAEjaLMY4ldtdy/Xy2qU7PJLBkq8ugLp8qicaKC+tpqxp6sGif4vLIjz7Ap5hurRbTNbXzszyyHA==",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.4.tgz",
"integrity": "sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.4.tgz",
"integrity": "sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.4.tgz",
"integrity": "sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.4.tgz",
"integrity": "sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.4.tgz",
"integrity": "sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.4.tgz",
"integrity": "sha512-DFuyYxgaZPgxum5z1yvJPbfYCvDdO8geXsdyqt0qYYdiat3aGE4ncJhiLRIFDhSHBhaZg5eCgu/YPYAN6jZnrA==",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.4.tgz",
"integrity": "sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.4.tgz",
"integrity": "sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.4.tgz",
"integrity": "sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.4.tgz",
"integrity": "sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.4.tgz",
"integrity": "sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.4.tgz",
"integrity": "sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.4.tgz",
"integrity": "sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.4.tgz",
"integrity": "sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.4.tgz",
"integrity": "sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.4.tgz",
"integrity": "sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.4.tgz",
"integrity": "sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.4.tgz",
"integrity": "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
"integrity": "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.4.tgz",
"integrity": "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.4.tgz",
"integrity": "sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.4.tgz",
"integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.22.4.tgz",
"integrity": "sha512-XIQZPwLakR1t8+Q1UeCpr+kUHDWxpJzGy9r2xUi3mpPd6Wh8dtNltScBkUlCcr0sqc6J1GF6Is02JJVQGmCZMA==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.22.4",
"@tiptap/extension-floating-menu": "^3.22.4"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.4.tgz",
"integrity": "sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==",
"dependencies": {
"@tiptap/core": "^3.22.4",
"@tiptap/extension-blockquote": "^3.22.4",
"@tiptap/extension-bold": "^3.22.4",
"@tiptap/extension-bullet-list": "^3.22.4",
"@tiptap/extension-code": "^3.22.4",
"@tiptap/extension-code-block": "^3.22.4",
"@tiptap/extension-document": "^3.22.4",
"@tiptap/extension-dropcursor": "^3.22.4",
"@tiptap/extension-gapcursor": "^3.22.4",
"@tiptap/extension-hard-break": "^3.22.4",
"@tiptap/extension-heading": "^3.22.4",
"@tiptap/extension-horizontal-rule": "^3.22.4",
"@tiptap/extension-italic": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-list": "^3.22.4",
"@tiptap/extension-list-item": "^3.22.4",
"@tiptap/extension-list-keymap": "^3.22.4",
"@tiptap/extension-ordered-list": "^3.22.4",
"@tiptap/extension-paragraph": "^3.22.4",
"@tiptap/extension-strike": "^3.22.4",
"@tiptap/extension-text": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/extensions": "^3.22.4",
"@tiptap/pm": "^3.22.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -1149,7 +1582,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1159,12 +1591,16 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
@ -1625,7 +2061,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@ -1894,6 +2329,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -2435,6 +2878,11 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -2552,6 +3000,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -2660,6 +3113,123 @@
"node": ">= 0.8.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2768,6 +3338,11 @@
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"license": "MIT"
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -2986,6 +3561,14 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
@ -3063,6 +3646,11 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -15,6 +15,11 @@
"dependencies": {
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.1",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/pm": "^3.22.4",
"@tiptap/react": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.9.0",

View file

@ -0,0 +1,212 @@
// ============================================================
// RichTextEditor — Tiptap/ProseMirror editor matching the vanilla
// tp-toolbar feature set from public/js/learningHub.js (@be14578).
//
// Toolbar: bold / italic / underline / strike | H2 / H3 |
// bulletList / orderedList / blockquote / codeBlock |
// link (with URL bar) | clear formatting
//
// Emits HTML on change — the CMS server stores body as HTML, so
// what you type here is what Learning Hub readers see.
//
// Variants:
// default — full toolbar (content body)
// mini — bold/italic/list/link only (short fields)
// option — bold/italic/link only (quiz option text)
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
type Variant = 'default' | 'mini' | 'option';
interface Props {
value: string;
onChange: (html: string) => void;
variant?: Variant;
placeholder?: string;
minHeight?: string; // tailwind arbitrary value, e.g. 'min-h-[280px]'
testId?: string;
}
const btn = 'inline-flex items-center justify-center min-w-[28px] h-7 px-2 rounded text-xs border border-transparent hover:bg-muted';
const btnActive = btn + ' bg-muted border-border';
const sep = 'inline-block w-px h-5 bg-border mx-1 align-middle';
function Tool({ on, active, title, children }: { on: () => void; active: boolean; title: string; children: React.ReactNode }) {
return (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); on(); }}
className={active ? btnActive : btn}
title={title}
>
{children}
</button>
);
}
function LinkBar({ editor, open, setOpen }: { editor: Editor; open: boolean; setOpen: (b: boolean) => void }) {
const [url, setUrl] = useState('');
useEffect(() => {
if (open) setUrl(editor.getAttributes('link').href || '');
}, [open, editor]);
if (!open) return null;
function apply() {
const trimmed = url.trim();
if (trimmed) editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
setOpen(false);
}
function remove() {
editor.chain().focus().unsetLink().run();
setOpen(false);
}
return (
<div className="flex items-center gap-1 px-2 py-1 border-t border-border bg-muted/30">
<input
type="url"
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); apply(); }
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
placeholder="https://"
className="flex-1 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<button type="button" onMouseDown={(e) => { e.preventDefault(); apply(); }}
className="rounded bg-primary text-primary-foreground px-2 py-1 text-xs">
Apply
</button>
<button type="button" onMouseDown={(e) => { e.preventDefault(); remove(); }}
className="rounded border border-border px-2 py-1 text-xs">
Remove
</button>
<button type="button" onMouseDown={(e) => { e.preventDefault(); setOpen(false); }}
className="rounded border border-border px-2 py-1 text-xs">
</button>
</div>
);
}
function Toolbar({ editor, variant }: { editor: Editor; variant: Variant }) {
const [linkOpen, setLinkOpen] = useState(false);
const toggleLink = useCallback(() => setLinkOpen((v) => !v), []);
const bold = (
<Tool on={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold"><strong>B</strong></Tool>
);
const italic = (
<Tool on={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic"><em>I</em></Tool>
);
const underline = (
<Tool on={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline"><span style={{ textDecoration: 'underline' }}>U</span></Tool>
);
const strike = (
<Tool on={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strike"><span style={{ textDecoration: 'line-through' }}>S</span></Tool>
);
const h2 = (
<Tool on={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">H2</Tool>
);
const h3 = (
<Tool on={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">H3</Tool>
);
const bullet = (
<Tool on={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"></Tool>
);
const ordered = (
<Tool on={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">1.</Tool>
);
const quote = (
<Tool on={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote"></Tool>
);
const code = (
<Tool on={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code">{'</>'}</Tool>
);
const link = (
<Tool on={toggleLink} active={editor.isActive('link')} title="Link">🔗</Tool>
);
const clear = (
<Tool on={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} active={false} title="Clear formatting"></Tool>
);
let buttons: React.ReactNode;
if (variant === 'option') {
buttons = <>{bold}{italic}{link}</>;
} else if (variant === 'mini') {
buttons = <>{bold}{italic}{bullet}{link}</>;
} else {
buttons = (
<>
{bold}{italic}{underline}{strike}
<span className={sep} />
{h2}{h3}
<span className={sep} />
{bullet}{ordered}{quote}{code}
<span className={sep} />
{link}
<span className={sep} />
{clear}
</>
);
}
return (
<>
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1 bg-muted/40 border-b border-border">
{buttons}
</div>
<LinkBar editor={editor} open={linkOpen} setOpen={setLinkOpen} />
</>
);
}
export default function RichTextEditor({
value, onChange, variant = 'default', placeholder, minHeight = 'min-h-[240px]', testId,
}: Props) {
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Underline,
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' } }),
],
content: value,
onUpdate: ({ editor: ed }) => onChange(ed.getHTML()),
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none dark:prose-invert px-3 py-2 ' + minHeight + ' focus:outline-none',
'data-placeholder': placeholder || '',
},
},
});
// Sync external value changes back into the editor (e.g. when the user
// switches to a different CMS item — parent updates `value`).
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) editor.commands.setContent(value || '', { emitUpdate: false });
}, [value, editor]);
if (!editor) return null;
return (
<div
className="rounded-md border border-input bg-background overflow-hidden"
data-testid={testId}
>
<Toolbar editor={editor} variant={variant} />
<EditorContent editor={editor} />
</div>
);
}

View file

@ -0,0 +1,332 @@
// ============================================================
// AiGenerator — generate Learning Hub content via AI from
// 1. a topic description
// 2. one or more uploaded files (PDF, DOCX, TXT, …)
// 3. a Nextcloud/WebDAV file the user picks from a browser
//
// Faithful port of public/js/learningHub.js (@be14578) functions
// openAiPanel / updateAiOptions / runAiGenerate / applyAiContent /
// browseWebdav. Posts to the existing /api/admin/learning/
// ai-generate endpoint as multipart/form-data; the response payload
// (title, subject, body, questions) is handed back to the parent
// via `onGenerated` so the Content Editor can apply it.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { CmsQuestion, ContentType } from './cms-types';
interface Props {
contentType: ContentType;
onChangeType: (t: ContentType) => void;
onGenerated: (payload: GeneratedPayload, contentType: ContentType) => void;
onCancel: () => void;
}
export interface GeneratedPayload {
title?: string;
subject?: string;
body?: string;
marpMarkdown?: string;
questions?: CmsQuestion[];
}
interface MeOk { success: true; user: { nextcloud_url?: string | null } }
interface WebdavItem { path: string; name: string; isDir: boolean; contentType?: string; size?: number }
interface WebdavOk { success: true; path: string; parentPath: string; items: WebdavItem[] }
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
type Tab = 'topic' | 'upload' | 'webdav';
export default function AiGenerator(props: Props) {
const [tab, setTab] = useState<Tab>('topic');
const [topic, setTopic] = useState('');
const [uploadCtx, setUploadCtx] = useState('');
const [webdavCtx, setWebdavCtx] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [refinement, setRefinement] = useState('');
const [wordCount, setWordCount] = useState('');
const [slideCount, setSlideCount] = useState('');
const [genQuestions, setGenQuestions] = useState(false);
const [qCount, setQCount] = useState(5);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const me = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
});
const webdavConnected = !!me.data?.user?.nextcloud_url;
const showWords = props.contentType !== 'quiz' && props.contentType !== 'presentation';
const showSlides = props.contentType === 'presentation';
const quizAlwaysOn = props.contentType === 'quiz';
const showQCount = quizAlwaysOn || genQuestions;
useEffect(() => {
if (quizAlwaysOn) setGenQuestions(true);
}, [quizAlwaysOn]);
async function run() {
setErr(null);
const fd = new FormData();
fd.append('contentType', props.contentType);
fd.append('questionCount', String(showQCount ? qCount : 0));
if (refinement.trim()) fd.append('refinement', refinement.trim());
if (showWords && wordCount) fd.append('wordCount', wordCount);
if (showSlides && slideCount) fd.append('slideCount', slideCount);
if (tab === 'topic') {
if (!topic.trim()) { setErr('Describe what you want AI to create'); return; }
fd.append('topic', topic.trim());
} else if (tab === 'upload') {
if (files.length === 0) { setErr('Select at least one file'); return; }
for (const f of files) fd.append('files', f);
if (uploadCtx.trim()) fd.append('topic', uploadCtx.trim());
} else {
// webdav
if (!selectedPath) { setErr('Select a file from Nextcloud'); return; }
fd.append('webdavPath', selectedPath);
if (webdavCtx.trim()) fd.append('topic', webdavCtx.trim());
}
setBusy(true);
try {
const r = await fetch('/api/admin/learning/ai-generate', {
method: 'POST',
credentials: 'include',
body: fd,
});
const data = await r.json();
if (!data.success) throw new Error(data.error || 'Generation failed');
// Presentation returns marpMarkdown + optional questions directly;
// articles/pearls/quizzes return everything nested under `content`.
const payload: GeneratedPayload = data.contentType === 'presentation'
? { marpMarkdown: data.marpMarkdown, questions: data.questions }
: (data.content || {});
props.onGenerated(payload, props.contentType);
} catch (e) {
setErr((e as ApiError).message || 'Generation failed');
} finally {
setBusy(false);
}
}
// WebDAV browser state (only used in the webdav tab)
const [currentPath, setCurrentPath] = useState('/');
const [webdavData, setWebdavData] = useState<WebdavOk | null>(null);
const [webdavErr, setWebdavErr] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState('');
const [selectedName, setSelectedName] = useState('');
useEffect(() => {
if (tab !== 'webdav' || !webdavConnected) return;
setWebdavErr(null);
api.get<WebdavOk>('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
.then(setWebdavData)
.catch((e) => setWebdavErr((e as ApiError).message || 'WebDAV browse failed'));
}, [tab, currentPath, webdavConnected]);
return (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 space-y-3" data-testid="cms-ai-panel">
<div className="flex items-center gap-2">
<strong className="text-sm"> Generate with AI</strong>
<button type="button" onClick={props.onCancel} className={btn + ' ml-auto'}> Close</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Type</span>
<select
className={input + ' w-full'}
value={props.contentType}
onChange={(e) => props.onChangeType(e.target.value as ContentType)}
data-testid="cms-ai-ctype"
>
<option value="article">Article</option>
<option value="pearl">Pearl</option>
<option value="quiz">Quiz</option>
<option value="presentation">Presentation</option>
</select>
</label>
{showWords && (
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Target word count (optional)</span>
<input
type="number"
min={100}
value={wordCount}
onChange={(e) => setWordCount(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 800"
/>
</label>
)}
{showSlides && (
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Slide count</span>
<input
type="number"
min={3}
max={40}
value={slideCount}
onChange={(e) => setSlideCount(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 10"
/>
</label>
)}
</div>
{!quizAlwaysOn && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={genQuestions}
onChange={(e) => setGenQuestions(e.target.checked)}
/>
Also generate quiz questions
</label>
)}
{showQCount && (
<label className="flex items-center gap-2 text-sm">
<span>Question count:</span>
<input
type="number"
min={1}
max={20}
value={qCount}
onChange={(e) => setQCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
className={input + ' w-20'}
/>
</label>
)}
<div className="flex gap-1 border-b border-border">
<button type="button" onClick={() => setTab('topic')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'topic' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Topic
</button>
<button type="button" onClick={() => setTab('upload')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'upload' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Upload files
</button>
{webdavConnected && (
<button type="button" onClick={() => setTab('webdav')}
className={'px-3 py-1.5 text-xs border-b-2 -mb-px ' + (tab === 'webdav' ? 'border-primary text-primary font-semibold' : 'border-transparent hover:bg-muted')}>
Nextcloud
</button>
)}
</div>
{tab === 'topic' && (
<textarea
className={input + ' w-full min-h-[100px] font-mono text-sm'}
placeholder="Describe what you want AI to create (e.g. 'Bronchiolitis management for attending teaching rounds, high school education level')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
data-testid="cms-ai-topic"
/>
)}
{tab === 'upload' && (
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.odt,.rtf,.txt,.md,.html,.htm,.pptx,.epub,image/*"
onChange={(e) => setFiles(Array.from(e.target.files || []))}
className="text-xs"
data-testid="cms-ai-files"
/>
{files.length > 0 && (
<ul className="text-xs text-muted-foreground">
{files.map((f, i) => <li key={i}>📄 {f.name} <span className="opacity-60">({Math.round(f.size / 1024)} KB)</span></li>)}
</ul>
)}
<textarea
className={input + ' w-full min-h-[60px] text-sm'}
placeholder="Optional: extra context / framing for the AI"
value={uploadCtx}
onChange={(e) => setUploadCtx(e.target.value)}
/>
</div>
)}
{tab === 'webdav' && (
<div className="space-y-2">
{webdavErr && <div className="text-sm text-destructive">{webdavErr}</div>}
{webdavData && (
<div className="rounded-md border border-border bg-background">
<div className="px-2 py-1 flex items-center gap-2 text-xs border-b border-border bg-muted/40">
<span className="font-mono text-muted-foreground">{webdavData.path}</span>
{currentPath !== '/' && (
<button type="button" onClick={() => setCurrentPath(webdavData.parentPath)} className={btn + ' ml-auto'}> Up</button>
)}
</div>
<div className="max-h-60 overflow-auto">
{webdavData.items.length === 0 && <div className="px-2 py-3 text-xs text-muted-foreground italic">Empty folder.</div>}
{webdavData.items.map((it) => (
<button
key={it.path}
type="button"
onClick={() => {
if (it.isDir) { setCurrentPath(it.path); setSelectedPath(''); setSelectedName(''); }
else { setSelectedPath(it.path); setSelectedName(it.name); }
}}
className={
'w-full text-left px-2 py-1.5 text-sm flex items-center gap-2 border-b border-border last:border-0 hover:bg-muted ' +
(selectedPath === it.path ? 'bg-primary/10' : '')
}
>
<span>{it.isDir ? '📁' : '📄'}</span>
<span className="truncate">{it.name}</span>
{!it.isDir && typeof it.size === 'number' && (
<span className="ml-auto text-[10px] text-muted-foreground">{Math.round((it.size || 0) / 1024)} KB</span>
)}
</button>
))}
</div>
</div>
)}
{selectedPath && (
<div className="text-xs text-muted-foreground">
Selected: <span className="font-semibold">{selectedName}</span>
</div>
)}
<textarea
className={input + ' w-full min-h-[60px] text-sm'}
placeholder="Optional: extra context / framing for the AI"
value={webdavCtx}
onChange={(e) => setWebdavCtx(e.target.value)}
/>
</div>
)}
<label className="block">
<span className="text-[11px] uppercase font-semibold text-muted-foreground">Refinement (optional)</span>
<input
type="text"
value={refinement}
onChange={(e) => setRefinement(e.target.value)}
className={input + ' w-full'}
placeholder="e.g. 'Keep it under 500 words', 'Focus on outpatient management'"
/>
</label>
{err && <div className="text-sm text-destructive">{err}</div>}
<div className="flex gap-2">
<button type="button" onClick={run} disabled={busy} className={btnPrimary} data-testid="cms-ai-run">
{busy ? '⌛ Generating…' : '✨ Generate content'}
</button>
</div>
</div>
);
}

View file

@ -11,8 +11,11 @@
import { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import RichTextEditor from '@/components/RichTextEditor';
import type { CmsCategory, CmsContentDetail, CmsQuestion, ContentType } from './cms-types';
import QuestionsEditor from './QuestionsEditor';
import SlideEditor from './SlideEditor';
import AiGenerator, { type GeneratedPayload } from './AiGenerator';
interface CategoriesOk { success: true; categories: CmsCategory[] }
interface ContentDetailOk { success: true; content: CmsContentDetail }
@ -38,6 +41,28 @@ export default function ContentEditor({ id, initialType, onClose }: Props) {
const [published, setPublished] = useState(false);
const [questions, setQuestions] = useState<CmsQuestion[]>([]);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [aiOpen, setAiOpen] = useState(false);
function applyAi(payload: GeneratedPayload, ctype: ContentType) {
setContentType(ctype);
// Presentation → body is the Marp markdown (becomes the slide-editor
// source after split by \n---\n).
if (ctype === 'presentation' && payload.marpMarkdown) {
setBody(payload.marpMarkdown);
// Extract title from first # heading so the editor can save immediately.
const titleMatch = payload.marpMarkdown.match(/^#\s+(.+)$/m);
if (titleMatch) setTitle(titleMatch[1]);
} else {
if (payload.title) setTitle(payload.title);
if (payload.subject !== undefined) setSubject(payload.subject);
if (payload.body !== undefined) setBody(payload.body);
}
if (payload.questions && payload.questions.length) {
setQuestions(payload.questions);
}
setAiOpen(false);
setMsg({ kind: 'ok', text: 'Content generated — review, then save.' });
}
const cats = useQuery<CategoriesOk>({
queryKey: ['cms-categories'],
@ -92,9 +117,23 @@ export default function ContentEditor({ id, initialType, onClose }: Props) {
<h3 className="text-base font-semibold">
{isNew ? 'New ' + contentType : 'Edit ' + contentType}
</h3>
<button type="button" onClick={onClose} className={btn}> Back to list</button>
<div className="flex gap-2">
<button type="button" onClick={() => setAiOpen((v) => !v)} className={btnPrimary} data-testid="cms-open-ai">
{aiOpen ? 'Hide AI' : 'Generate with AI'}
</button>
<button type="button" onClick={onClose} className={btn}> Back to list</button>
</div>
</div>
{aiOpen && (
<AiGenerator
contentType={contentType}
onChangeType={setContentType}
onGenerated={applyAi}
onCancel={() => setAiOpen(false)}
/>
)}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Title</span>
@ -159,21 +198,23 @@ export default function ContentEditor({ id, initialType, onClose }: Props) {
/>
</label>
<label className="block">
<div>
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Body {contentType === 'presentation' && <span className="normal-case font-normal">(JSON slide array)</span>}
Body
</span>
<textarea
className={input + ' w-full min-h-[280px] font-mono text-sm'}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={contentType === 'presentation'
? '[ { "title": "Intro", "body": "..." }, ... ]'
: 'Markdown / HTML body'
}
data-testid="cms-edit-body"
/>
</label>
{contentType === 'presentation' ? (
<SlideEditor value={body} onChange={setBody} />
) : (
<RichTextEditor
value={body}
onChange={setBody}
variant="default"
minHeight="min-h-[320px]"
placeholder="Write the content body…"
testId="cms-edit-body"
/>
)}
</div>
<div className="flex items-center gap-2">
<button

View file

@ -6,6 +6,7 @@ import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import RichTextEditor from '@/components/RichTextEditor';
import type { CmsQuestion } from './cms-types';
interface Props {
@ -111,15 +112,16 @@ export default function QuestionsEditor(props: Props) {
</button>
</div>
</div>
<textarea
className={input + ' w-full min-h-[60px]'}
placeholder="Question text"
<RichTextEditor
value={q.question_text}
onChange={(e) => patchQ(qIdx, { question_text: e.target.value })}
onChange={(html) => patchQ(qIdx, { question_text: html })}
variant="mini"
minHeight="min-h-[60px]"
placeholder="Question text"
/>
<div className="space-y-1">
{(q.options || []).map((o, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<div key={optIdx} className="flex items-start gap-2">
<input
type={q.question_type === 'multi' ? 'checkbox' : 'radio'}
name={'q-' + qIdx + '-correct'}
@ -135,31 +137,35 @@ export default function QuestionsEditor(props: Props) {
}));
}
}}
className="mt-2"
/>
<input
type="text"
className={input + ' flex-1'}
placeholder={'Option ' + (optIdx + 1)}
value={o.option_text}
onChange={(e) => patchOpt(qIdx, optIdx, { option_text: e.target.value })}
/>
<input
type="text"
className={input + ' w-48 text-xs'}
placeholder="Per-option explanation"
value={o.explanation || ''}
onChange={(e) => patchOpt(qIdx, optIdx, { explanation: e.target.value })}
/>
<button type="button" onClick={() => removeOpt(qIdx, optIdx)} className="text-xs text-destructive">×</button>
<div className="flex-1 space-y-1">
<RichTextEditor
value={o.option_text}
onChange={(html) => patchOpt(qIdx, optIdx, { option_text: html })}
variant="option"
minHeight="min-h-[40px]"
placeholder={'Option ' + (optIdx + 1)}
/>
<RichTextEditor
value={o.explanation || ''}
onChange={(html) => patchOpt(qIdx, optIdx, { explanation: html })}
variant="option"
minHeight="min-h-[32px]"
placeholder="Per-option explanation (optional)"
/>
</div>
<button type="button" onClick={() => removeOpt(qIdx, optIdx)} className="text-xs text-destructive mt-2">×</button>
</div>
))}
<button type="button" onClick={() => addOpt(qIdx)} className={btn}>+ Option</button>
</div>
<textarea
className={input + ' w-full min-h-[40px] text-xs'}
placeholder="Question explanation (shown after answering)"
<RichTextEditor
value={q.explanation || ''}
onChange={(e) => patchQ(qIdx, { explanation: e.target.value })}
onChange={(html) => patchQ(qIdx, { explanation: html })}
variant="mini"
minHeight="min-h-[40px]"
placeholder="Question explanation (shown after answering)"
/>
</div>
))}

View file

@ -0,0 +1,107 @@
// ============================================================
// SlideEditor — presentation body editor. Vanilla stores slide
// decks in the `body` column as a single string with slides
// separated by `\n---\n` (see public/js/learningHub.js around
// line 338 where it does body.split(/\n---\n/).length).
//
// Each slide is a rich-text block, and the editor joins them
// back together with the `---` separator before handing the
// string up to ContentEditor.
// ============================================================
import { useMemo, useState } from 'react';
import RichTextEditor from '@/components/RichTextEditor';
interface Props {
value: string;
onChange: (next: string) => void;
}
const SEP = '\n---\n';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
function splitSlides(s: string): string[] {
if (!s) return [''];
return s.split(/\n-{3,}\n/);
}
export default function SlideEditor({ value, onChange }: Props) {
const slides = useMemo(() => splitSlides(value), [value]);
const [active, setActive] = useState(0);
const safeActive = Math.min(active, slides.length - 1);
function updateSlides(next: string[]) {
onChange(next.join(SEP));
}
function updateSlide(i: number, next: string) {
const copy = slides.slice();
copy[i] = next;
updateSlides(copy);
}
function addSlide(after: number) {
const copy = slides.slice();
copy.splice(after + 1, 0, '');
updateSlides(copy);
setActive(after + 1);
}
function removeSlide(i: number) {
if (slides.length <= 1) { updateSlides(['']); setActive(0); return; }
const copy = slides.slice();
copy.splice(i, 1);
updateSlides(copy);
setActive(Math.max(0, Math.min(active, copy.length - 1)));
}
function move(i: number, dir: -1 | 1) {
const j = i + dir;
if (j < 0 || j >= slides.length) return;
const copy = slides.slice();
[copy[i], copy[j]] = [copy[j], copy[i]];
updateSlides(copy);
setActive(j);
}
return (
<div className="rounded-md border border-input bg-background" data-testid="cms-slide-editor">
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border bg-muted/40">
<span className="text-xs font-semibold text-muted-foreground">
Slide {safeActive + 1} of {slides.length}
</span>
<div className="flex flex-wrap items-center gap-1 ml-2">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setActive(i)}
className={
'w-7 h-7 rounded text-xs border ' +
(i === safeActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted')
}
aria-label={'Slide ' + (i + 1)}
>{i + 1}</button>
))}
</div>
<div className="ml-auto flex gap-1">
<button type="button" onClick={() => move(safeActive, -1)} disabled={safeActive === 0} className={btn}> Move</button>
<button type="button" onClick={() => move(safeActive, 1)} disabled={safeActive === slides.length - 1} className={btn}>Move </button>
<button type="button" onClick={() => addSlide(safeActive)} className={btnPrimary}>+ Slide</button>
<button type="button" onClick={() => removeSlide(safeActive)} className={btn + ' text-destructive'}>Remove</button>
</div>
</div>
<RichTextEditor
key={safeActive}
value={slides[safeActive] || ''}
onChange={(html) => updateSlide(safeActive, html)}
variant="default"
minHeight="min-h-[260px]"
placeholder="Slide content…"
/>
</div>
);
}