feat : update

This commit is contained in:
2026-09-04 23:30:42 +07:00 Unverified
parent 890b88d4f1
commit b4f9fb0abb
12 changed files with 689 additions and 196 deletions
+1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
+110 -2
View File
@@ -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=="],
+59 -184
View File
@@ -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
+4 -7
View File
@@ -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>
+19
View File
@@ -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) }}
/>
);
}
+301
View File
@@ -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>
</>
);
}
+9 -1
View File
@@ -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;
});
+22
View File
@@ -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", () => {
+7 -2
View File
@@ -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(),
+40
View File
@@ -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>&lt;unsafe&gt;<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>");
});
});
+108
View File
@@ -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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeHtml(value: string): string {
return escapeAttribute(value).replaceAll("'", "&#39;");
}
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>` : "";
}
+9
View File
@@ -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",