feat : update
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-placeholder": "3.31.3",
|
||||
"@tiptap/extension-text-style": "3.31.3",
|
||||
"@tiptap/extension-underline": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"better-auth": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -485,6 +494,68 @@
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/[email protected]", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="],
|
||||
|
||||
"@tiptap/core": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/pm": "3.31.3" } }, "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg=="],
|
||||
|
||||
"@tiptap/extension-blockquote": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ=="],
|
||||
|
||||
"@tiptap/extension-bold": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ=="],
|
||||
|
||||
"@tiptap/extension-bubble-menu": ["@tiptap/[email protected]", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g=="],
|
||||
|
||||
"@tiptap/extension-bullet-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.3" } }, "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw=="],
|
||||
|
||||
"@tiptap/extension-code": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg=="],
|
||||
|
||||
"@tiptap/extension-code-block": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA=="],
|
||||
|
||||
"@tiptap/extension-document": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA=="],
|
||||
|
||||
"@tiptap/extension-dropcursor": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.31.3" } }, "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg=="],
|
||||
|
||||
"@tiptap/extension-floating-menu": ["@tiptap/[email protected]", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A=="],
|
||||
|
||||
"@tiptap/extension-gapcursor": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.31.3" } }, "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg=="],
|
||||
|
||||
"@tiptap/extension-hard-break": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ=="],
|
||||
|
||||
"@tiptap/extension-heading": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig=="],
|
||||
|
||||
"@tiptap/extension-horizontal-rule": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw=="],
|
||||
|
||||
"@tiptap/extension-italic": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ=="],
|
||||
|
||||
"@tiptap/extension-link": ["@tiptap/[email protected]", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw=="],
|
||||
|
||||
"@tiptap/extension-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg=="],
|
||||
|
||||
"@tiptap/extension-list-item": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.3" } }, "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA=="],
|
||||
|
||||
"@tiptap/extension-list-keymap": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.3" } }, "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q=="],
|
||||
|
||||
"@tiptap/extension-ordered-list": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extension-list": "3.31.3" } }, "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw=="],
|
||||
|
||||
"@tiptap/extension-paragraph": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA=="],
|
||||
|
||||
"@tiptap/extension-placeholder": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/extensions": "3.31.3" } }, "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A=="],
|
||||
|
||||
"@tiptap/extension-strike": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A=="],
|
||||
|
||||
"@tiptap/extension-text": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="],
|
||||
|
||||
"@tiptap/extension-text-style": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A=="],
|
||||
|
||||
"@tiptap/extension-underline": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="],
|
||||
|
||||
"@tiptap/extensions": ["@tiptap/[email protected]", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="],
|
||||
|
||||
"@tiptap/pm": ["@tiptap/[email protected]", "", { "dependencies": { "prosemirror-changeset": "^2.4.1", "prosemirror-commands": "^1.7.1", "prosemirror-dropcursor": "^1.8.2", "prosemirror-gapcursor": "^1.4.1", "prosemirror-history": "^1.5.0", "prosemirror-inputrules": "^1.5.1", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.11", "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.5", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.42.3" } }, "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag=="],
|
||||
|
||||
"@tiptap/react": ["@tiptap/[email protected]", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.31.3", "@tiptap/extension-floating-menu": "^3.31.3" }, "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3", "@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" } }, "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg=="],
|
||||
|
||||
"@tiptap/starter-kit": ["@tiptap/[email protected]", "", { "dependencies": { "@tiptap/core": "3.31.3", "@tiptap/extension-blockquote": "3.31.3", "@tiptap/extension-bold": "3.31.3", "@tiptap/extension-bullet-list": "3.31.3", "@tiptap/extension-code": "3.31.3", "@tiptap/extension-code-block": "3.31.3", "@tiptap/extension-document": "3.31.3", "@tiptap/extension-dropcursor": "3.31.3", "@tiptap/extension-gapcursor": "3.31.3", "@tiptap/extension-hard-break": "3.31.3", "@tiptap/extension-heading": "3.31.3", "@tiptap/extension-horizontal-rule": "3.31.3", "@tiptap/extension-italic": "3.31.3", "@tiptap/extension-link": "3.31.3", "@tiptap/extension-list": "3.31.3", "@tiptap/extension-list-item": "3.31.3", "@tiptap/extension-list-keymap": "3.31.3", "@tiptap/extension-ordered-list": "3.31.3", "@tiptap/extension-paragraph": "3.31.3", "@tiptap/extension-strike": "3.31.3", "@tiptap/extension-text": "3.31.3", "@tiptap/extension-underline": "3.31.3", "@tiptap/extensions": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A=="],
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/[email protected]", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
@@ -941,6 +1012,8 @@
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["[email protected]", "", {}, "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -953,7 +1026,6 @@
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
|
||||
"figures": ["[email protected]", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
@@ -1218,6 +1290,8 @@
|
||||
|
||||
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"linkifyjs": ["[email protected]", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="],
|
||||
|
||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["[email protected]", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
@@ -1320,6 +1394,8 @@
|
||||
|
||||
"ora": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
|
||||
"orderedmap": ["[email protected]", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
|
||||
|
||||
"own-keys": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
|
||||
|
||||
"p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -1360,7 +1436,7 @@
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"postgres": ["[email protected]", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
@@ -1374,6 +1450,32 @@
|
||||
|
||||
"prop-types": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"prosemirror-changeset": ["[email protected]", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w=="],
|
||||
|
||||
"prosemirror-commands": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw=="],
|
||||
|
||||
"prosemirror-dropcursor": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ=="],
|
||||
|
||||
"prosemirror-gapcursor": ["[email protected]", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="],
|
||||
|
||||
"prosemirror-history": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
|
||||
|
||||
"prosemirror-inputrules": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
|
||||
|
||||
"prosemirror-keymap": ["[email protected]", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
|
||||
|
||||
"prosemirror-model": ["[email protected]", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ=="],
|
||||
|
||||
"prosemirror-schema-list": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
|
||||
|
||||
"prosemirror-state": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
|
||||
|
||||
"prosemirror-tables": ["[email protected]", "", { "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" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
|
||||
|
||||
"prosemirror-transform": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA=="],
|
||||
|
||||
"prosemirror-view": ["[email protected]", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w=="],
|
||||
|
||||
"proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
@@ -1432,6 +1534,8 @@
|
||||
|
||||
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
|
||||
|
||||
"rope-sequence": ["[email protected]", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
||||
|
||||
"rou3": ["[email protected]", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
|
||||
|
||||
"router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
@@ -1634,6 +1738,8 @@
|
||||
|
||||
"vitest": ["[email protected]", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
|
||||
|
||||
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["[email protected]", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -1788,6 +1894,8 @@
|
||||
|
||||
"shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"shadcn/postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
|
||||
"shadcn/undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"shadcn/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ChartNoAxesCombinedIcon,
|
||||
@@ -96,6 +97,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import {
|
||||
@@ -161,6 +163,7 @@ import {
|
||||
normalizePercentInput,
|
||||
} from "@/lib/guides/percent-input";
|
||||
import { formatGuideValidationIssues } from "@/lib/guides/validation-messages";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CatalogItem = {
|
||||
@@ -260,7 +263,13 @@ type TeamValue = {
|
||||
|
||||
let clientIdCounter = 0;
|
||||
const DEFAULT_EXTRA_BACKGROUND_COLOR = "#24232C";
|
||||
const DEFAULT_EXTRA_HIGHLIGHT_COLOR = "#FF3B30";
|
||||
const RichTextEditor = dynamic(
|
||||
() => import("@/components/rich-text-editor").then((module) => module.RichTextEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-36 w-full" />,
|
||||
},
|
||||
);
|
||||
|
||||
function createClientId(prefix: string): string {
|
||||
const browserCrypto = globalThis.crypto;
|
||||
@@ -3308,10 +3317,15 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
clientId: section.id,
|
||||
rows: section.rows.map((row) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
highlights: [],
|
||||
clientId: row.id,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
const [openSectionIds, setOpenSectionIds] = useState(() =>
|
||||
initial.extras.map((section) => section.id),
|
||||
);
|
||||
const value = {
|
||||
sections: sections.map((section) => ({
|
||||
title: section.title,
|
||||
@@ -3324,7 +3338,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
? null
|
||||
: Number(row.percent),
|
||||
backgroundColor: row.backgroundColor,
|
||||
highlights: row.highlights.map(({ text, color }) => ({ text, color })),
|
||||
highlights: [],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
@@ -3345,20 +3359,23 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
localDraft,
|
||||
parseLocalDraft: (candidate) =>
|
||||
parseEditorDraft<typeof localDraft>(candidate, ["sections"]),
|
||||
restoreLocalDraft: (draft) => setSections(
|
||||
draft.sections.map((section) => ({
|
||||
restoreLocalDraft: (draft) => {
|
||||
const restoredSections = draft.sections.map((section) => ({
|
||||
...section,
|
||||
id: section.id || createClientId("extra-section-record"),
|
||||
clientId: section.clientId || createClientId("extra-section"),
|
||||
rows: section.rows.map((row) => ({
|
||||
...row,
|
||||
id: row.id || createClientId("extra-row-record"),
|
||||
body: normalizeExtraRichText(row.body, row.highlights ?? []),
|
||||
backgroundColor: row.backgroundColor ?? null,
|
||||
highlights: row.highlights ?? [],
|
||||
highlights: [],
|
||||
clientId: row.clientId || createClientId("extra-row"),
|
||||
})),
|
||||
})),
|
||||
),
|
||||
}));
|
||||
setSections(restoredSections);
|
||||
setOpenSectionIds(restoredSections.map((section) => section.clientId));
|
||||
},
|
||||
});
|
||||
const handleSectionDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
@@ -3384,7 +3401,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
<CardHeader>
|
||||
<CardTitle>Extra sections</CardTitle>
|
||||
<CardDescription>
|
||||
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
|
||||
เพิ่มส่วนเสริมแบบภาพ หัวข้อ ข้อความที่จัดรูปแบบได้ และเปอร์เซ็นต์
|
||||
ระบบจะบันทึกอัตโนมัติ
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -3393,6 +3410,12 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
sensors={extraSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSectionDragEnd}
|
||||
>
|
||||
<Accordion
|
||||
multiple
|
||||
value={openSectionIds}
|
||||
onValueChange={setOpenSectionIds}
|
||||
className="gap-5"
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map((section) => section.clientId)}
|
||||
@@ -3405,6 +3428,7 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
label={`ลากเพื่อจัดลำดับ ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
||||
>
|
||||
{(sectionDragHandle) => (
|
||||
<AccordionItem value={section.clientId} className="border-0">
|
||||
<Card
|
||||
size="sm"
|
||||
className={cn(
|
||||
@@ -3445,8 +3469,15 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">ลบส่วน</span>
|
||||
</DeleteConfirmationDialog>
|
||||
<AccordionTrigger
|
||||
className="size-8 flex-none items-center justify-center p-0 hover:no-underline"
|
||||
headerClassName="flex-none"
|
||||
aria-label={`${openSectionIds.includes(section.clientId) ? "ซ่อน" : "แสดง"} ${section.title || `Extra section ${sectionIndex + 1}`}`}
|
||||
title={openSectionIds.includes(section.clientId) ? "ซ่อนส่วนนี้" : "แสดงส่วนนี้"}
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<AccordionContent className="pb-0">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<DndContext
|
||||
sensors={extraSensors}
|
||||
@@ -3624,9 +3655,16 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>ข้อความ</FieldLabel>
|
||||
<Textarea
|
||||
<RichTextEditor
|
||||
value={row.body}
|
||||
onChange={(event) =>
|
||||
invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"body",
|
||||
)}
|
||||
onChange={(body) =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
@@ -3636,7 +3674,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
body: event.target.value,
|
||||
body,
|
||||
highlights: [],
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
@@ -3769,175 +3808,6 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
ใช้สีตามธีม
|
||||
</Button>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<FieldLegend>ไฮไลต์คำหรือวลี</FieldLegend>
|
||||
<FieldDescription>
|
||||
ระบุข้อความให้ตรงกับเนื้อหา ระบบจะเปลี่ยนสีทุกตำแหน่งที่พบ
|
||||
</FieldDescription>
|
||||
{row.highlights.map((highlight, highlightIndex) => (
|
||||
<FieldGroup
|
||||
key={highlightIndex}
|
||||
className="sm:grid sm:grid-cols-[1fr_auto_auto] sm:items-end"
|
||||
>
|
||||
<Field
|
||||
data-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"highlights",
|
||||
highlightIndex,
|
||||
"text",
|
||||
) || undefined}
|
||||
>
|
||||
<FieldLabel>คำหรือวลี</FieldLabel>
|
||||
<Input
|
||||
value={highlight.text}
|
||||
placeholder="MASSIVE"
|
||||
aria-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"highlights",
|
||||
highlightIndex,
|
||||
"text",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
highlights: candidate.highlights.map((rule, ruleIndex) =>
|
||||
ruleIndex === highlightIndex
|
||||
? { ...rule, text: event.target.value }
|
||||
: rule,
|
||||
),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
data-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"highlights",
|
||||
highlightIndex,
|
||||
"color",
|
||||
) || undefined}
|
||||
>
|
||||
<FieldLabel>สี</FieldLabel>
|
||||
<Input
|
||||
type="color"
|
||||
value={highlight.color}
|
||||
aria-invalid={extraIssue(
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"rows",
|
||||
rowIndex,
|
||||
"highlights",
|
||||
highlightIndex,
|
||||
"color",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
highlights: candidate.highlights.map((rule, ruleIndex) =>
|
||||
ruleIndex === highlightIndex
|
||||
? { ...rule, color: event.target.value }
|
||||
: rule,
|
||||
),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`ลบไฮไลต์ ${highlight.text || highlightIndex + 1}`}
|
||||
onClick={() =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
highlights: candidate.highlights.filter((_, ruleIndex) => ruleIndex !== highlightIndex),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={row.highlights.length >= 20}
|
||||
onClick={() =>
|
||||
setSections(
|
||||
sections.map((item, index) =>
|
||||
index === sectionIndex
|
||||
? {
|
||||
...item,
|
||||
rows: item.rows.map((candidate, inner) =>
|
||||
inner === rowIndex
|
||||
? {
|
||||
...candidate,
|
||||
highlights: [
|
||||
...candidate.highlights,
|
||||
{ text: "", color: DEFAULT_EXTRA_HIGHLIGHT_COLOR },
|
||||
],
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่มคำไฮไลต์
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -3981,27 +3851,32 @@ function ExtraEditor({ guide, initial }: EditorBase) {
|
||||
เพิ่มแถว
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AccordionContent>
|
||||
</Card>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</SortableExtraItem>
|
||||
))}
|
||||
</SortableContext>
|
||||
</Accordion>
|
||||
</DndContext>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
const clientId = createClientId("extra-section");
|
||||
setSections([
|
||||
...sections,
|
||||
{
|
||||
id: createClientId("extra-section-record"),
|
||||
title: "Extra",
|
||||
rows: [],
|
||||
clientId: createClientId("extra-section"),
|
||||
clientId,
|
||||
},
|
||||
])
|
||||
}
|
||||
]);
|
||||
setOpenSectionIds([...openSectionIds, clientId]);
|
||||
}}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
เพิ่ม Extra section
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { TeamSearch } from "@/components/public/team-search";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
||||
import { RichTextContent } from "@/components/rich-text-content";
|
||||
import { LiveRefresh } from "@/components/live-refresh";
|
||||
import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
talentTypeLabel,
|
||||
} from "@/lib/guides/talents";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { highlightTextSegments } from "@/lib/guides/highlights";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function hasSection(guide: PublicGuide, slug: string): boolean {
|
||||
@@ -1029,12 +1030,8 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
|
||||
) : null}
|
||||
<CardHeader className="pt-5">
|
||||
<CardTitle>{row.heading}</CardTitle>
|
||||
<CardDescription className="whitespace-pre-wrap">
|
||||
{highlightTextSegments(row.body, row.highlights).map((segment, index) => (
|
||||
<span key={index} style={segment.color ? { color: segment.color } : undefined}>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
<CardDescription>
|
||||
<RichTextContent html={normalizeExtraRichText(row.body, row.highlights)} />
|
||||
</CardDescription>
|
||||
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
|
||||
</CardHeader>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sanitizeRichTextHtml } from "@/lib/rich-text";
|
||||
|
||||
export function RichTextContent({
|
||||
html,
|
||||
className,
|
||||
}: {
|
||||
html: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!html) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("prose prose-sm prose-invert max-w-none leading-snug [&>p]:m-0", className)}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeRichTextHtml(html) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Color, TextStyle } from "@tiptap/extension-text-style";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import {
|
||||
BoldIcon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
PaletteIcon,
|
||||
RemoveFormattingIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
UnlinkIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import {
|
||||
sanitizeRichTextHtml,
|
||||
sanitizeRichTextUrl,
|
||||
} from "@/lib/rich-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
type Format = "bold" | "italic" | "underline" | "strike";
|
||||
|
||||
function normalizedEditorHtml(editor: Editor): string {
|
||||
const html = sanitizeRichTextHtml(editor.getHTML());
|
||||
return html === "<p></p>" ? "" : html;
|
||||
}
|
||||
|
||||
function FormatControls({
|
||||
editor,
|
||||
onLink,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const formats: Format[] = ["bold", "italic", "underline", "strike"];
|
||||
const active = formats.filter((format) => editor.isActive(format));
|
||||
const color = editor.getAttributes("textStyle").color ?? "#FF3B30";
|
||||
|
||||
const toggleFormat = (format: Format) => {
|
||||
const chain = editor.chain().focus();
|
||||
if (format === "bold") chain.toggleBold().run();
|
||||
if (format === "italic") chain.toggleItalic().run();
|
||||
if (format === "underline") chain.toggleUnderline().run();
|
||||
if (format === "strike") chain.toggleStrike().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ToggleGroup
|
||||
multiple
|
||||
value={active}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
aria-label="รูปแบบข้อความ"
|
||||
onValueChange={(next) => {
|
||||
const changed = formats.find((format) => active.includes(format) !== next.includes(format));
|
||||
if (changed) toggleFormat(changed);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="bold" aria-label="ตัวหนา" title="ตัวหนา">
|
||||
<BoldIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="ตัวเอียง" title="ตัวเอียง">
|
||||
<ItalicIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="ขีดเส้นใต้" title="ขีดเส้นใต้">
|
||||
<UnderlineIcon />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="strike" aria-label="ขีดฆ่า" title="ขีดฆ่า">
|
||||
<StrikethroughIcon />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
<label className="relative grid size-8 cursor-pointer place-items-center rounded-lg border border-input hover:bg-muted" title="สีข้อความ">
|
||||
<PaletteIcon aria-hidden="true" />
|
||||
<span className="sr-only">สีข้อความ</span>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
onChange={(event) => editor.chain().focus().setColor(event.target.value).run()}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ล้างสีข้อความ"
|
||||
title="ล้างสีข้อความ"
|
||||
onClick={() => editor.chain().focus().unsetColor().run()}
|
||||
>
|
||||
<RemoveFormattingIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={editor.isActive("link") ? "ลบลิงก์" : "เพิ่มลิงก์"}
|
||||
title={editor.isActive("link") ? "ลบลิงก์" : "เพิ่มลิงก์"}
|
||||
onClick={onLink}
|
||||
>
|
||||
{editor.isActive("link") ? <UnlinkIcon /> : <LinkIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "เขียนข้อความ...",
|
||||
className,
|
||||
invalid = false,
|
||||
}: RichTextEditorProps) {
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkText, setLinkText] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [linkInvalid, setLinkInvalid] = useState(false);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary underline underline-offset-4",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
isAllowedUri: (url) => Boolean(sanitizeRichTextUrl(url)),
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: value,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor: currentEditor }) => onChange(normalizedEditorHtml(currentEditor)),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm prose-invert min-h-28 max-w-none px-3 py-2 leading-snug outline-none [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&>p]:m-0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const next = sanitizeRichTextHtml(value);
|
||||
if (normalizedEditorHtml(editor) !== next) {
|
||||
editor.commands.setContent(next, { emitUpdate: false });
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const openLinkDialog = () => {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
const { from, to } = editor.state.selection;
|
||||
setLinkText(editor.state.doc.textBetween(from, to, " "));
|
||||
setLinkUrl("");
|
||||
setLinkInvalid(false);
|
||||
setLinkOpen(true);
|
||||
};
|
||||
|
||||
const saveLink = () => {
|
||||
const safeUrl = sanitizeRichTextUrl(linkUrl);
|
||||
if (!safeUrl) {
|
||||
setLinkInvalid(true);
|
||||
return;
|
||||
}
|
||||
const chain = editor.chain().focus();
|
||||
if (linkText) {
|
||||
chain.insertContent({
|
||||
type: "text",
|
||||
text: linkText,
|
||||
marks: [{ type: "link", attrs: { href: safeUrl } }],
|
||||
}).run();
|
||||
} else {
|
||||
chain.extendMarkRange("link").setLink({ href: safeUrl }).run();
|
||||
}
|
||||
setLinkOpen(false);
|
||||
setLinkText("");
|
||||
setLinkUrl("");
|
||||
setLinkInvalid(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-background focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
|
||||
invalid && "border-destructive ring-3 ring-destructive/20",
|
||||
className,
|
||||
)}
|
||||
aria-invalid={invalid || undefined}
|
||||
>
|
||||
<div className="border-b bg-muted/50 p-1">
|
||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||
</div>
|
||||
<BubbleMenu editor={editor} className="rounded-lg border bg-popover p-1 shadow-md">
|
||||
<FormatControls editor={editor} onLink={openLinkDialog} />
|
||||
</BubbleMenu>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<Dialog open={linkOpen} onOpenChange={setLinkOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>เพิ่มลิงก์</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rich-text-link-text">ข้อความ</FieldLabel>
|
||||
<Input
|
||||
id="rich-text-link-text"
|
||||
value={linkText}
|
||||
placeholder="ข้อความลิงก์"
|
||||
onChange={(event) => setLinkText(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field data-invalid={linkInvalid || undefined}>
|
||||
<FieldLabel htmlFor="rich-text-link-url">URL</FieldLabel>
|
||||
<Input
|
||||
id="rich-text-link-url"
|
||||
value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
aria-invalid={linkInvalid}
|
||||
autoFocus
|
||||
onChange={(event) => {
|
||||
setLinkUrl(event.target.value);
|
||||
setLinkInvalid(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
saveLink();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{linkInvalid ? (
|
||||
<FieldDescription>ใช้ลิงก์ http, https หรือ mailto เท่านั้น</FieldDescription>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setLinkOpen(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type="button" onClick={saveLink}>
|
||||
บันทึกลิงก์
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
weaponSectionSchema,
|
||||
} from "@/lib/guides/schemas";
|
||||
import { processCatalogSync } from "@/lib/catalog/sync";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
const DEFAULT_SECTIONS = [
|
||||
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
|
||||
@@ -208,7 +209,14 @@ export async function saveExtraSections(guideId: string, input: unknown) {
|
||||
for (const [sortOrder, section] of data.sections.entries()) {
|
||||
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
|
||||
await tx.insert(extraSections).values({ sectionId: createdSection.id });
|
||||
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
|
||||
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
highlights: [],
|
||||
percent: row.percent?.toString() ?? null,
|
||||
sectionId: createdSection.id,
|
||||
sortOrder: rowOrder,
|
||||
})));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -158,6 +158,28 @@ describe("structured guide validation", () => {
|
||||
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
|
||||
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
|
||||
});
|
||||
|
||||
it("sanitizes Extra rich text and allows formatting markup", () => {
|
||||
const result = extraSectionSchema.safeParse({
|
||||
expectedVersion: 1,
|
||||
sections: [{
|
||||
title: "Extra",
|
||||
rows: [{
|
||||
mediaId: null,
|
||||
heading: "Formatted card",
|
||||
body: '<p><strong>Safe</strong><script>bad()</script></p>',
|
||||
percent: null,
|
||||
backgroundColor: null,
|
||||
highlights: [],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].rows[0].body).toBe("<p><strong>Safe</strong></p>");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Calc", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
|
||||
import { targetStatOptions } from "@/lib/guides/stats";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
export type GuideSectionKind = (typeof guideSectionKinds)[number];
|
||||
@@ -136,7 +137,7 @@ const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลื
|
||||
const extraRowSchema = z.strictObject({
|
||||
mediaId: z.string().uuid().nullable(),
|
||||
heading: z.string().trim().min(1).max(200),
|
||||
body: z.string().trim().max(2_000),
|
||||
body: z.string().trim().max(10_000),
|
||||
percent: percent.nullable(),
|
||||
backgroundColor: hexColor.nullable().default(null),
|
||||
highlights: z.array(z.strictObject({
|
||||
@@ -146,7 +147,11 @@ const extraRowSchema = z.strictObject({
|
||||
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
|
||||
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
|
||||
).default([]),
|
||||
});
|
||||
}).transform((row) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
highlights: [],
|
||||
}));
|
||||
|
||||
export const extraSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
normalizeExtraRichText,
|
||||
sanitizeRichTextHtml,
|
||||
sanitizeRichTextUrl,
|
||||
} from "@/lib/rich-text";
|
||||
|
||||
describe("rich text sanitization", () => {
|
||||
it("keeps supported formatting, colors, and safe links", () => {
|
||||
expect(sanitizeRichTextHtml(
|
||||
'<p><strong>Good</strong> <span style="color: #ff3b30">news</span> <a href="https://example.com" onclick="bad()">link</a></p>',
|
||||
)).toBe(
|
||||
'<p><strong>Good</strong> <span style="color: #FF3B30">news</span> <a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("removes executable markup, unsafe links, and unsupported styles", () => {
|
||||
expect(sanitizeRichTextHtml(
|
||||
'<script>alert(1)</script><p onclick="bad()">Safe <span style="background:red;color:red">text</span><a href="javascript:bad()">link</a></p>',
|
||||
)).toBe("<p>Safe <span>text</span><a>link</a></p>");
|
||||
expect(sanitizeRichTextUrl("data:text/html,bad")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy Extra rich text conversion", () => {
|
||||
it("escapes plain text, preserves newlines, and converts phrase colors", () => {
|
||||
expect(normalizeExtraRichText("<unsafe>\nMASSIVE damage", [
|
||||
{ text: "MASSIVE", color: "#ff0000" },
|
||||
])).toBe(
|
||||
'<p><unsafe><br><span style="color: #FF0000">MASSIVE</span> damage</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves sanitized rich text in rich-text form", () => {
|
||||
expect(normalizeExtraRichText('<p><em>Ready</em></p>', [
|
||||
{ text: "Ready", color: "#ff0000" },
|
||||
])).toBe("<p><em>Ready</em></p>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"span",
|
||||
"strong",
|
||||
"u",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
const BLOCKED_CONTENT = /<(script|style|iframe|object|embed|svg|math|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
||||
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
||||
const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
|
||||
const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export function sanitizeRichTextUrl(value: string): string {
|
||||
const url = value.trim();
|
||||
if (!url) return "";
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, "https://example.invalid");
|
||||
return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return escapeAttribute(value).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function attributeValue(rawAttributes: string, name: string): string {
|
||||
for (const match of rawAttributes.matchAll(ATTRIBUTE)) {
|
||||
if (match[1].toLowerCase() === name) {
|
||||
return match[2].replace(/^['"]|['"]$/g, "");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function sanitizeRichTextHtml(value: string): string {
|
||||
return value
|
||||
.replace(BLOCKED_CONTENT, "")
|
||||
.replace(HTML_COMMENT, "")
|
||||
.replace(TAG, (tag, rawName: string, rawAttributes: string) => {
|
||||
const name = rawName.toLowerCase();
|
||||
if (!ALLOWED_TAGS.has(name)) return "";
|
||||
if (tag.startsWith("</")) return name === "br" ? "" : `</${name}>`;
|
||||
if (name === "br") return "<br>";
|
||||
|
||||
if (name === "a") {
|
||||
const href = sanitizeRichTextUrl(attributeValue(rawAttributes, "href"));
|
||||
return href
|
||||
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
|
||||
: "<a>";
|
||||
}
|
||||
|
||||
if (name === "span") {
|
||||
const style = attributeValue(rawAttributes, "style");
|
||||
const color = style.match(/(?:^|;)\s*color\s*:\s*(#[0-9a-fA-F]{6})\s*(?:;|$)/)?.[1];
|
||||
return color && HEX_COLOR.test(color)
|
||||
? `<span style="color: ${color.toUpperCase()}">`
|
||||
: "<span>";
|
||||
}
|
||||
|
||||
return `<${name}>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function isRichTextHtml(value: string): boolean {
|
||||
return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value);
|
||||
}
|
||||
|
||||
export function normalizeExtraRichText(
|
||||
body: string,
|
||||
highlights: readonly TextHighlight[] = [],
|
||||
): string {
|
||||
if (isRichTextHtml(body)) return sanitizeRichTextHtml(body);
|
||||
|
||||
const content = highlightTextSegments(body, highlights)
|
||||
.map((segment) => {
|
||||
const text = escapeHtml(segment.text).replaceAll("\n", "<br>");
|
||||
return segment.color && HEX_COLOR.test(segment.color)
|
||||
? `<span style="color: ${segment.color.toUpperCase()}">${text}</span>`
|
||||
: text;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return content ? `<p>${content}</p>` : "";
|
||||
}
|
||||
@@ -25,6 +25,15 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-placeholder": "3.31.3",
|
||||
"@tiptap/extension-text-style": "3.31.3",
|
||||
"@tiptap/extension-underline": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"better-auth": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Reference in New Issue
Block a user