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:
parent
5342fd1205
commit
b2ef470180
7 changed files with 1332 additions and 41 deletions
594
client/package-lock.json
generated
594
client/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
212
client/src/components/RichTextEditor.tsx
Normal file
212
client/src/components/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
client/src/pages/cms/AiGenerator.tsx
Normal file
332
client/src/pages/cms/AiGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
107
client/src/pages/cms/SlideEditor.tsx
Normal file
107
client/src/pages/cms/SlideEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue