diff --git a/client/package-lock.json b/client/package-lock.json index da215b7..2e02ccc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 243764c..fa75a03 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/RichTextEditor.tsx b/client/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..3b5d8f0 --- /dev/null +++ b/client/src/components/RichTextEditor.tsx @@ -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 ( + + ); +} + +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 ( +
+ 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" + /> + + + +
+ ); +} + +function Toolbar({ editor, variant }: { editor: Editor; variant: Variant }) { + const [linkOpen, setLinkOpen] = useState(false); + + const toggleLink = useCallback(() => setLinkOpen((v) => !v), []); + + const bold = ( + editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">B + ); + const italic = ( + editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">I + ); + const underline = ( + editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline">U + ); + const strike = ( + editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strike">S + ); + const h2 = ( + editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">H2 + ); + const h3 = ( + editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">H3 + ); + const bullet = ( + editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">• + ); + const ordered = ( + editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">1. + ); + const quote = ( + editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">❝ + ); + const code = ( + editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code">{''} + ); + const link = ( + 🔗 + ); + const clear = ( + editor.chain().focus().unsetAllMarks().clearNodes().run()} active={false} title="Clear formatting">⌫ + ); + + 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} + + {h2}{h3} + + {bullet}{ordered}{quote}{code} + + {link} + + {clear} + + ); + } + + return ( + <> +
+ {buttons} +
+ + + ); +} + +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 ( +
+ + +
+ ); +} diff --git a/client/src/pages/cms/AiGenerator.tsx b/client/src/pages/cms/AiGenerator.tsx new file mode 100644 index 0000000..98fd863 --- /dev/null +++ b/client/src/pages/cms/AiGenerator.tsx @@ -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('topic'); + const [topic, setTopic] = useState(''); + const [uploadCtx, setUploadCtx] = useState(''); + const [webdavCtx, setWebdavCtx] = useState(''); + const [files, setFiles] = useState([]); + 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(null); + const fileInputRef = useRef(null); + + const me = useQuery({ + queryKey: ['auth-me'], + queryFn: () => api.get('/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(null); + const [webdavErr, setWebdavErr] = useState(null); + const [selectedPath, setSelectedPath] = useState(''); + const [selectedName, setSelectedName] = useState(''); + + useEffect(() => { + if (tab !== 'webdav' || !webdavConnected) return; + setWebdavErr(null); + api.get('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath)) + .then(setWebdavData) + .catch((e) => setWebdavErr((e as ApiError).message || 'WebDAV browse failed')); + }, [tab, currentPath, webdavConnected]); + + return ( +
+
+ ✨ Generate with AI + +
+ +
+ + {showWords && ( + + )} + {showSlides && ( + + )} +
+ + {!quizAlwaysOn && ( + + )} + {showQCount && ( + + )} + +
+ + + {webdavConnected && ( + + )} +
+ + {tab === 'topic' && ( +