diff --git a/bun.lock b/bun.lock index aaadc6e..01d6496 100644 --- a/bun.lock +++ b/bun.lock @@ -15,11 +15,26 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-code-block-lowlight": "^3.24.0", + "@tiptap/extension-image": "^3.24.0", + "@tiptap/extension-table": "^3.24.0", + "@tiptap/extension-table-cell": "^3.24.0", + "@tiptap/extension-table-header": "^3.24.0", + "@tiptap/extension-table-row": "^3.24.0", + "@tiptap/extension-task-item": "^3.24.0", + "@tiptap/extension-task-list": "^3.24.0", + "@tiptap/extension-text-align": "^3.24.0", + "@tiptap/extension-youtube": "^3.24.0", + "@tiptap/pm": "^3.24.0", + "@tiptap/react": "^3.24.0", + "@tiptap/starter-kit": "^3.24.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "jotai": "^2.19.0", + "lowlight": "^3.3.0", "lucide-react": "^0.562.0", + "marked": "^18.0.4", "next": "^16.0.10", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", @@ -320,14 +335,96 @@ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tiptap/core": ["@tiptap/core@3.24.0", "https://registry.npmmirror.com/@tiptap/core/-/core-3.24.0.tgz", { "peerDependencies": { "@tiptap/pm": "3.24.0" } }, "sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-blockquote/-/extension-blockquote-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-DgwEEJ1GbDQcT054ynxoaZGmB9apGeUklPrinq9o6xdLHpdg+bO9HCQzggdB8n21VLLglb8jfAEWsVNwh3eASQ=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bold/-/extension-bold-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.24.0.tgz", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code/-/extension-code-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code-block/-/extension-code-block-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-NZglw4oHoH6oJ5+HvxxQCYk+wODJmsxzUpRQdsOmje08sekQH+Zt9i4UKimBhg4urpd5r+dKXTslab9a5eQ86w=="], + + "@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/extension-code-block": "3.24.0", "@tiptap/pm": "3.24.0", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-DwXmsymZj1xbfKm92wvCf4rq3XuF2akVlYbxu2HBXedZaUIDMz0+kxaFRlsMxbsZUTmjW3wYChPqeFUrZ4cq8g=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-document/-/extension-document-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-yxgM3+yXy2XZzEwH43y2Kp8D1BkblxEWLXqo0YCoAKtxyKCcEaT8kdlf70kS7D0+VSzYU4D0iN7VdQIYHcL2mA=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.24.0.tgz", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-Dbv1c5LnvG3PT+yEbCNroyOeeUkHq9wcir2pbC7wri7g7d2sCi0+HvKH0MAxLwY3j5NJJSiSyG2ypMaXOAs4sg=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.24.0.tgz", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.24.0.tgz", { "peerDependencies": { "@tiptap/extensions": "3.24.0" } }, "sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-hard-break/-/extension-hard-break-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-T/ZEBiHQPMyTqDvXG0tiqBToNeuSemIPmNtdoGSgBN/degVl7VJZqQIrLIvOUHfjf3QkRs7TE/mcqTJsIboO/g=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-heading/-/extension-heading-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-GCSgapIzQPqEGNcVGE0/Pcjg5wITMLYJlrS3GGVw7BPmECJwgexcoOsEwkxtzJnXT/HpFXbvOFW43sM0KeHSjg=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw=="], + + "@tiptap/extension-image": ["@tiptap/extension-image@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-image/-/extension-image-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mH+bvsX2cPKuZzV7YMQi4FV2YbDP+Kmq36bY+Bwi/x4mYUc8u0cjQxcu8RzLO7GtsgUJPxGMwfkQxmDqXFLZvw=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-italic/-/extension-italic-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-link/-/extension-link-3.24.0.tgz", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-MwMoNGG2mL5XGFV1tEGunBRglwsIbW+ZOB2QnKiv+Mcbi2JCWMrorndJZBqpVPR5nM+Bef2KnpchEJmYlQLvKQ=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-GcxDVMMmDGj7OFTBrV7JpVgr5wxlr2vmjwH7U8QxZX7OJI5vrsMYl/U6KRTvUpG8wP+Zmo5jRlLM+BbL+a/W3g=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list-item/-/extension-list-item-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-zl/U3viJiV9OzkKM37AHIUN1af1TSLrcbHUUoNLkfJ33Nq+NlpaXpCVK0rKRqiLFJf7zk/a5KWG5CrOy9TxjKA=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-69fKcrngYGEKWNn4R5oLwl0YuV3FY4kufEValVcjnihUmqJTE1vx+fwctYoTsOGnIuNGpUIQ7f9YDD/0w34qBw=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-buRa6bmBDw0TztH+rAcusIye14DiLDS+yGheo6GiNCTD7kKJnksXagBdxvip3jhW5sx7gyAKvoBmvGSg1BbsGA=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-paragraph/-/extension-paragraph-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-strike/-/extension-strike-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ=="], + + "@tiptap/extension-table": ["@tiptap/extension-table@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table/-/extension-table-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-lr5elob3uJnB+ltgqPDEeVQmIPRx6JoS0I6z93tOgKsI2mIsaw5ErghteeiCTpExdyax7aWR0fn5pZzLVDQL8A=="], + + "@tiptap/extension-table-cell": ["@tiptap/extension-table-cell@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-cell/-/extension-table-cell-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-jg62iVc1L8tOycSuwZ5i82EGKGuvelCIkpFX7wvHMl9EiPX9fv70enmb4ua9DyLgRtYg/MNrGOdUATRpxWsFRA=="], + + "@tiptap/extension-table-header": ["@tiptap/extension-table-header@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-header/-/extension-table-header-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-z1TGVoBD5lVX48hpKcrQY5vETB8aCR61Enk5wGl45zNO/sMEMNxYfDKTkMydyOFUWfGqvOPnjDHJqJuT36/19w=="], + + "@tiptap/extension-table-row": ["@tiptap/extension-table-row@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-table-row/-/extension-table-row-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-table": "3.24.0" } }, "sha512-kASEYMWZF9ZqO6DRGz0LiD11c3LPfzOlFiJQVDrOyLP7xkJpHzIAKH0K+8u4aohOziDLn8nz+8I+3xjRS46oGg=="], + + "@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-task-item/-/extension-task-item-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-2VtX6VOp4SJk5E/VHeSC2Acfbge6h69TUBhF7hFebbiCDyB73xCaPKcuClIyzOlWJgpWz2ZPXwVB+Y3meXw+yw=="], + + "@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-task-list/-/extension-task-list-3.24.0.tgz", { "peerDependencies": { "@tiptap/extension-list": "3.24.0" } }, "sha512-EIbB/WQ7L92WfaY5AF9upH93nIgBHjGt5zwPYoX+ZTpBncebr1AEDF7rc+LHNuSa1g1ToOKbFB88ep02IIXOjA=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-text/-/extension-text-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg=="], + + "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-text-align/-/extension-text-align-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-WKFtYXGthtkUc+Cwy2fItSr+9FKwLZjkJVAY1GhkRdcq35qTuVhkb4Q4wR2Rhkb6QRqtlxF1NDuTf2vxiQmfBQ=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-underline/-/extension-underline-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA=="], + + "@tiptap/extension-youtube": ["@tiptap/extension-youtube@3.24.0", "https://registry.npmmirror.com/@tiptap/extension-youtube/-/extension-youtube-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0" } }, "sha512-/irhKRjUgUV1qGlCld9fbMLZfrjkjJq2Kr5RwJLctafkBRprzn80vyTsxQmi+b65wOJ9Wnsy/v3hnD5OrrbiyA=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.24.0", "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.24.0.tgz", { "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0" } }, "sha512-z6gRYzy2ucJp07OQ0F2W07NxyhMTxPYH1ia2eGiQkWax1i56oExpjMsDHP8THWlg8Tb7NnbfKpkfh881EsmofA=="], + + "@tiptap/pm": ["@tiptap/pm@3.24.0", "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.24.0.tgz", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg=="], + + "@tiptap/react": ["@tiptap/react@3.24.0", "https://registry.npmmirror.com/@tiptap/react/-/react-3.24.0.tgz", { "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.24.0", "@tiptap/extension-floating-menu": "^3.24.0" }, "peerDependencies": { "@tiptap/core": "3.24.0", "@tiptap/pm": "3.24.0", "@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-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.24.0", "https://registry.npmmirror.com/@tiptap/starter-kit/-/starter-kit-3.24.0.tgz", { "dependencies": { "@tiptap/core": "^3.24.0", "@tiptap/extension-blockquote": "^3.24.0", "@tiptap/extension-bold": "^3.24.0", "@tiptap/extension-bullet-list": "^3.24.0", "@tiptap/extension-code": "^3.24.0", "@tiptap/extension-code-block": "^3.24.0", "@tiptap/extension-document": "^3.24.0", "@tiptap/extension-dropcursor": "^3.24.0", "@tiptap/extension-gapcursor": "^3.24.0", "@tiptap/extension-hard-break": "^3.24.0", "@tiptap/extension-heading": "^3.24.0", "@tiptap/extension-horizontal-rule": "^3.24.0", "@tiptap/extension-italic": "^3.24.0", "@tiptap/extension-link": "^3.24.0", "@tiptap/extension-list": "^3.24.0", "@tiptap/extension-list-item": "^3.24.0", "@tiptap/extension-list-keymap": "^3.24.0", "@tiptap/extension-ordered-list": "^3.24.0", "@tiptap/extension-paragraph": "^3.24.0", "@tiptap/extension-strike": "^3.24.0", "@tiptap/extension-text": "^3.24.0", "@tiptap/extension-underline": "^3.24.0", "@tiptap/extensions": "^3.24.0", "@tiptap/pm": "^3.24.0" } }, "sha512-Ef4PCP96vcY2GonXN9J0M8iC6zvxPTmQlL/QZiCwuYqqnH/hNpYIjNSQdTndiDpxRKofa32Sr2HWktgEnL32Bg=="], + "@types/bun": ["@types/bun@1.3.5", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.5.tgz", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/node": ["@types/node@20.17.24", "https://registry.npmmirror.com/@types/node/-/node-20.17.24.tgz", { "dependencies": { "undici-types": "6.19.8" } }, "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA=="], "@types/react": ["@types/react@19.2.7", "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "https://registry.npmmirror.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], @@ -348,16 +445,24 @@ "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "fast-equals": ["fast-equals@5.4.0", "https://registry.npmmirror.com/fast-equals/-/fast-equals-5.4.0.tgz", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jotai": ["jotai@2.19.0", "https://registry.npmmirror.com/jotai/-/jotai-2.19.0.tgz", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-r2wwxEXP1F2JteDLZEOPoIpAHhV89paKsN5GWVYndPNMMP/uVZDcC+fNj0A8NjKgaPWzdyO8Vp8YcYKe0uCEqQ=="], @@ -386,20 +491,54 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "linkifyjs": ["linkifyjs@4.3.3", "https://registry.npmmirror.com/linkifyjs/-/linkifyjs-4.3.3.tgz", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], + + "lowlight": ["lowlight@3.3.0", "https://registry.npmmirror.com/lowlight/-/lowlight-3.3.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], + "lucide-react": ["lucide-react@0.562.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.4", "https://registry.npmmirror.com/marked/-/marked-18.0.4.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], + "nanoid": ["nanoid@3.3.9", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.9.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg=="], "next": ["next@16.1.1", "https://registry.npmmirror.com/next/-/next-16.1.1.tgz", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="], "next-themes": ["next-themes@0.4.6", "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "orderedmap": ["orderedmap@2.1.1", "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "postcss": ["postcss@8.5.3", "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", { "dependencies": { "nanoid": "3.3.9", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "https://registry.npmmirror.com/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "https://registry.npmmirror.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "https://registry.npmmirror.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "https://registry.npmmirror.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", { "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": ["prosemirror-history@1.5.0", "https://registry.npmmirror.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz", { "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": ["prosemirror-inputrules@1.5.1", "https://registry.npmmirror.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "https://registry.npmmirror.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-model": ["prosemirror-model@1.25.7", "https://registry.npmmirror.com/prosemirror-model/-/prosemirror-model-1.25.7.tgz", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "https://registry.npmmirror.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "https://registry.npmmirror.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "https://registry.npmmirror.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", { "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": ["prosemirror-transform@1.12.0", "https://registry.npmmirror.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + + "prosemirror-view": ["prosemirror-view@1.41.8", "https://registry.npmmirror.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA=="], + "radix-ui": ["radix-ui@1.4.3", "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "react": ["react@19.2.3", "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -414,6 +553,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "rope-sequence": ["rope-sequence@1.3.4", "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.3", "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -448,6 +589,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zustand": ["zustand@5.0.9", "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], diff --git a/package.json b/package.json index 6d47d84..e3af360 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,26 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-code-block-lowlight": "^3.24.0", + "@tiptap/extension-image": "^3.24.0", + "@tiptap/extension-table": "^3.24.0", + "@tiptap/extension-table-cell": "^3.24.0", + "@tiptap/extension-table-header": "^3.24.0", + "@tiptap/extension-table-row": "^3.24.0", + "@tiptap/extension-task-item": "^3.24.0", + "@tiptap/extension-task-list": "^3.24.0", + "@tiptap/extension-text-align": "^3.24.0", + "@tiptap/extension-youtube": "^3.24.0", + "@tiptap/pm": "^3.24.0", + "@tiptap/react": "^3.24.0", + "@tiptap/starter-kit": "^3.24.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "jotai": "^2.19.0", + "lowlight": "^3.3.0", "lucide-react": "^0.562.0", + "marked": "^18.0.4", "next": "^16.0.10", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", diff --git a/src/actions/article-group.ts b/src/actions/article-group.ts new file mode 100644 index 0000000..ce15f2c --- /dev/null +++ b/src/actions/article-group.ts @@ -0,0 +1,44 @@ +"use server" + +import type { PageRecord } from "@/lib/api" +import type { ArticleGroup } from "@/models/article-group" +import { callByUser } from "./base" + +export async function getArticleGroupList() { + return callByUser("/api/admin/article-group/list") +} + +export async function getArticleGroupPage(params?: { + page?: number + size?: number + keyword?: string + status?: number +}) { + return callByUser>( + "/api/admin/article-group/page", + params, + ) +} + +export async function createArticleGroup(data: { + code: string + name: string + sort?: number + status?: number +}) { + return callByUser("/api/admin/article-group/create", data) +} + +export async function updateArticleGroup(data: { + id: number + code?: string + name?: string + sort?: number + status?: number +}) { + return callByUser("/api/admin/article-group/update", data) +} + +export async function removeArticleGroup(id: number) { + return callByUser("/api/admin/article-group/remove", { id }) +} diff --git a/src/actions/article.ts b/src/actions/article.ts new file mode 100644 index 0000000..c7e9738 --- /dev/null +++ b/src/actions/article.ts @@ -0,0 +1,47 @@ +"use server" + +import type { PageRecord } from "@/lib/api" +import type { Article, UploadImageResult } from "@/models/article" +import { callByUser, callByUserUpload } from "./base" + +export async function getArticlePage(params: { page: number; size: number }) { + return callByUser>("/api/admin/article/page", params) +} + +export async function getArticle(id: number) { + return callByUser
("/api/admin/article/get", { id }) +} + +export async function createArticle(data: { + title: string + group_id: number + content?: string + sort?: number + status?: number +}) { + return callByUser
("/api/admin/article/create", data) +} + +export async function updateArticle(data: { + id: number + title?: string + content?: string + group_id?: number + sort?: number + status?: number +}) { + return callByUser
("/api/admin/article/update", data) +} + +export async function removeArticle(id: number) { + return callByUser("/api/admin/article/remove", { id }) +} + +export async function uploadArticleImage(file: File) { + const formData = new FormData() + formData.append("file", file) + return callByUserUpload( + "/api/admin/article/upload", + formData, + ) +} diff --git a/src/actions/base.ts b/src/actions/base.ts index 1de21ad..98ce0e0 100644 --- a/src/actions/base.ts +++ b/src/actions/base.ts @@ -1,6 +1,6 @@ "use server" import { cookies, headers } from "next/headers" -import { redirect } from "next/navigation" +// import { redirect } from "next/navigation" import { cache } from "react" import { API_BASE_URL, @@ -164,21 +164,108 @@ async function call( throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`) } -async function postCall(rawResp: Promise>) { - const header = await headers() - const pathname = header.get("x-pathname") || "/" - const resp = await rawResp +// async function postCall(rawResp: Promise>) { +// const header = await headers() +// const pathname = header.get("x-pathname") || "/" +// const resp = await rawResp - // 重定向到登录页 - const match = [/^\/admin.*/].some(item => item.test(pathname)) +// // 重定向到登录页 +// const match = [/^\/admin.*/].some(item => item.test(pathname)) - if (match && !resp.success && resp.status === 401) { - console.log("🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗") - redirect("/login?force=true") +// if (match && !resp.success && resp.status === 401) { +// console.log("🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗") +// redirect("/login?force=true") +// } + +// return resp +// } + +// ====================== +// upload +// ====================== + +async function callByUserUpload( + endpoint: string, + formData: FormData, +): Promise> { + const cookie = await cookies() + const token = cookie.get("admin/auth_token")?.value + if (!token) { + return { + success: false, + status: 401, + message: "会话已失效", + } } - return resp + return callUpload( + `${API_BASE_URL}${endpoint}`, + formData, + `Bearer ${token}`, + ) +} + +async function callUpload( + url: string, + body: FormData, + auth?: string, +): Promise> { + let response: Response + try { + const reqHeaders = await headers() + const reqIP = reqHeaders.get("x-forwarded-for") + const reqUA = reqHeaders.get("user-agent") + const callHeaders: RequestInit["headers"] = {} + if (auth) callHeaders["Authorization"] = auth + if (reqIP) callHeaders["X-Forwarded-For"] = reqIP + if (reqUA) callHeaders["User-Agent"] = reqUA + + response = await fetch(url, { + method: "POST", + headers: callHeaders, + body, + }) + } catch (e) { + console.error("后端请求失败", url, (e as Error).message) + throw new Error(`请求失败,网络错误`) + } + + const type = response.headers.get("Content-Type") ?? "text/plain" + if (type.indexOf("text/plain") !== -1) { + const text = await response.text() + if (!response.ok) { + console.log("后端请求失败", url, `status=${response.status}`, text) + return { + success: false, + status: response.status, + message: text || "请求失败", + } + } + if (text?.trim()?.length) { + console.log("未处理的响应成功", `type=text`, `text=${text}`) + } + return { + success: true, + data: undefined as R, + } + } else if (type.indexOf("application/json") !== -1) { + const json = await response.json() + if (!response.ok) { + console.log("后端请求失败", url, `status=${response.status}`, json) + return { + success: false, + status: response.status, + message: json.message || json.error_description || "请求失败", + } + } + return { + success: true, + data: json, + } + } + + throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`) } // 导出 -export { callPublic, callByDevice, callByUser } +export { callPublic, callByDevice, callByUser, callByUserUpload } diff --git a/src/app/(root)/(dashboard)/page.tsx b/src/app/(root)/(dashboard)/page.tsx index 2752ab5..66f8ff2 100644 --- a/src/app/(root)/(dashboard)/page.tsx +++ b/src/app/(root)/(dashboard)/page.tsx @@ -112,7 +112,7 @@ export default function DashboardPage() { // 处理取消 const handleCancel = () => { setShowCustomPicker(false) - setTimeRange("7d") + setTimeRange("7d") } return ( @@ -232,18 +232,18 @@ export default function DashboardPage() { {/* 点击自定义时显示 */} {showCustomPicker && (
- setStartDate(e.target.value)} + onChange={e => setStartDate(e.target.value)} /> ~ - setEndDate(e.target.value)} + onChange={e => setEndDate(e.target.value)} />
) -} \ No newline at end of file +} diff --git a/src/app/(root)/_navigation/index.tsx b/src/app/(root)/_navigation/index.tsx index 3d4221b..7c564be 100644 --- a/src/app/(root)/_navigation/index.tsx +++ b/src/app/(root)/_navigation/index.tsx @@ -10,9 +10,11 @@ import { DollarSign, DoorClosedIcon, FolderCode, + FolderTree, Home, KeyRound, type LucideIcon, + Newspaper, Package, ScanSearch, Shield, @@ -217,6 +219,16 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ label: "产品管理", requiredScope: ScopeProductRead, }, + { + href: "/articles", + icon: Newspaper, + label: "文章管理", + }, + { + href: "/article-groups", + icon: FolderTree, + label: "文章分组", + }, { href: "/discount", icon: SquarePercent, diff --git a/src/app/(root)/appbar.tsx b/src/app/(root)/appbar.tsx index 97eab58..d746895 100644 --- a/src/app/(root)/appbar.tsx +++ b/src/app/(root)/appbar.tsx @@ -59,6 +59,7 @@ export default function Appbar(props: { admin: Admin }) { dashboard: "控制台", content: "内容管理", articles: "文章管理", + "article-groups": "文章分组", media: "媒体库", user: "客户认领", roles: "角色权限", @@ -82,9 +83,12 @@ export default function Appbar(props: { admin: Admin }) { balance: "余额明细", gateway: "网关列表", couponList: "已发放优惠券", + new: "新建文章", } - return labels[path] || path + if (labels[path]) return labels[path] + if (/^\d+$/.test(path)) return "编辑文章" + return path } const breadcrumbs = generateBreadcrumbs() diff --git a/src/app/(root)/article-groups/page.tsx b/src/app/(root)/article-groups/page.tsx new file mode 100644 index 0000000..642ed3c --- /dev/null +++ b/src/app/(root)/article-groups/page.tsx @@ -0,0 +1,208 @@ +"use client" + +import { Loader2 } from "lucide-react" +import { Suspense, useCallback, useState } from "react" +import { toast } from "sonner" +import { + createArticleGroup, + getArticleGroupPage, + removeArticleGroup, + updateArticleGroup, +} from "@/actions/article-group" +import { DataTable, useDataTable } from "@/components/data-table" +import { Page } from "@/components/page" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import type { ArticleGroup } from "@/models/article-group" +import { formatDate } from "@/models/formatDate" + +export default function ArticleGroupPage() { + const fetchFn = useCallback( + (page: number, size: number) => getArticleGroupPage({ page, size }), + [], + ) + + const table = useDataTable(fetchFn) + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [sort, setSort] = useState("0") + const [saving, setSaving] = useState(false) + + const openCreate = () => { + setEditing(null) + setCode("") + setName("") + setSort("0") + setDialogOpen(true) + } + + const openEdit = (group: ArticleGroup) => { + setEditing(group) + setCode(group.code) + setName(group.name) + setSort(String(group.sort)) + setDialogOpen(true) + } + + const handleSave = useCallback(async () => { + if (!code.trim() || !name.trim()) return + setSaving(true) + try { + if (editing) { + const resp = await updateArticleGroup({ + id: editing.id, + code: code.trim(), + name: name.trim(), + sort: Number(sort), + }) + if (resp.success) { + toast.success("分组更新成功") + setDialogOpen(false) + table.refresh() + } else { + toast.error(resp.message || "更新失败") + } + } else { + const resp = await createArticleGroup({ + code: code.trim(), + name: name.trim(), + sort: Number(sort), + }) + if (resp.success) { + toast.success("分组创建成功") + setDialogOpen(false) + table.refresh() + } else { + toast.error(resp.message || "创建失败") + } + } + } finally { + setSaving(false) + } + }, [code, name, sort, editing, table]) + + const handleDelete = useCallback( + async (id: number) => { + if (!confirm("确定要删除该分组吗?")) return + const resp = await removeArticleGroup(id) + if (resp.success) { + toast.success("分组已删除") + table.refresh() + } else { + toast.error(resp.message || "删除失败") + } + }, + [table], + ) + + return ( + +
+

文章分组

+ +
+ + + + {...table} + columns={[ + { header: "编码", accessorKey: "code" }, + { header: "名称", accessorKey: "name" }, + { header: "排序", accessorKey: "sort" }, + { + header: "创建时间", + accessorKey: "created_at", + cell: ({ row }) => formatDate(row.original.created_at), + }, + { + header: "操作", + id: "actions", + cell: ({ row }) => ( +
+ + +
+ ), + }, + ]} + /> +
+ + + + + {editing ? "编辑分组" : "新建分组"} + +
+
+ + setCode(e.target.value)} + placeholder="分组编码,如 news" + className="mt-1" + /> +
+
+ + setName(e.target.value)} + placeholder="分组名称" + className="mt-1" + /> +
+
+ + setSort(e.target.value)} + placeholder="0" + className="mt-1" + /> +
+
+ + + + +
+
+
+ ) +} diff --git a/src/app/(root)/articles/[id]/article-editor.tsx b/src/app/(root)/articles/[id]/article-editor.tsx new file mode 100644 index 0000000..647a616 --- /dev/null +++ b/src/app/(root)/articles/[id]/article-editor.tsx @@ -0,0 +1,135 @@ +"use client" + +import { Loader2 } from "lucide-react" +import dynamic from "next/dynamic" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import { createArticle, getArticle, updateArticle } from "@/actions/article" +import type { ArticleGroup } from "@/models/article-group" + +const Editor = dynamic(() => import("@/components/editor/richTextEditor"), { + ssr: false, + loading: () => ( +
+ 编辑器加载中... +
+ ), +}) + +interface ArticleEditorProps { + articleId: string + groups: ArticleGroup[] +} + +export default function ArticleEditor({ + articleId, + groups, +}: ArticleEditorProps) { + const router = useRouter() + const isNew = articleId === "new" + const [loading, setLoading] = useState(!isNew) + const [title, setTitle] = useState("") + const [initialContent, setInitialContent] = useState("") + const [groupId, setGroupId] = useState( + groups.length > 0 ? String(groups[0].id) : "", + ) + + useEffect(() => { + if (!isNew) { + const id = Number(articleId) + if (Number.isNaN(id)) { + toast.error("无效的文章 ID") + router.push("/articles") + return + } + getArticle(id).then(resp => { + if (resp.success && resp.data) { + setTitle(resp.data.title || "") + setInitialContent(resp.data.content || "") + if (resp.data.group_id) { + setGroupId(String(resp.data.group_id)) + } + } else { + toast.error( + resp.success ? "文章数据为空" : resp.message || "文章不存在", + ) + router.push("/articles") + } + setLoading(false) + }) + } + }, [articleId, isNew, router]) + + const handleSave = useCallback( + async (data: { title: string; content: string }) => { + if (!groupId) { + toast.error("请选择文章分组") + return + } + if (isNew) { + const resp = await createArticle({ + title: data.title, + content: data.content, + group_id: Number(groupId), + status: 1, + }) + if (resp.success) { + toast.success("文章创建成功") + router.push("/articles") + } else { + toast.error(resp.message || "创建失败") + } + } else { + const id = Number(articleId) + const resp = await updateArticle({ + id, + title: data.title, + content: data.content, + group_id: Number(groupId), + status: 1, + }) + if (resp.success) { + toast.success("文章保存成功") + router.push("/articles") + } else { + toast.error(resp.message || "保存失败") + } + } + }, + [isNew, articleId, groupId, router], + ) + + if (loading) { + return ( +
+ +
+ ) + } + + if (groups.length === 0) { + return ( +
+

还没有文章分组,请先创建分组

+ + 前往创建分组 + +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/src/app/(root)/articles/[id]/page.tsx b/src/app/(root)/articles/[id]/page.tsx new file mode 100644 index 0000000..38e8eab --- /dev/null +++ b/src/app/(root)/articles/[id]/page.tsx @@ -0,0 +1,22 @@ +import { getArticleGroupList } from "@/actions/article-group" +import { Page } from "@/components/page" +import ArticleEditor from "./article-editor" + +export default async function ArticleEditPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const groupsResp = await getArticleGroupList() + console.log(groupsResp, "groupsResp") + + const groups = groupsResp.success ? groupsResp.data : [] + console.log(groups, "groups") + + return ( + + + + ) +} diff --git a/src/app/(root)/articles/page.tsx b/src/app/(root)/articles/page.tsx new file mode 100644 index 0000000..7f34c70 --- /dev/null +++ b/src/app/(root)/articles/page.tsx @@ -0,0 +1,105 @@ +"use client" + +import Link from "next/link" +import { useRouter } from "next/navigation" +import { Suspense, useCallback } from "react" +import { toast } from "sonner" +import { getArticlePage, removeArticle } from "@/actions/article" +import { DataTable, useDataTable } from "@/components/data-table" +import { Page } from "@/components/page" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import type { Article } from "@/models/article" +import { formatDate } from "@/models/formatDate" + +export default function ArticleListPage() { + const router = useRouter() + const fetchFn = useCallback( + (page: number, size: number) => getArticlePage({ page, size }), + [], + ) + + const table = useDataTable(fetchFn) + console.log(table, "tabletabletable") + + const handleDelete = useCallback( + async (id: number, title: string) => { + if (!confirm(`确定要删除文章《${title}》吗?此操作不可恢复!`)) return + + try { + const resp = await removeArticle(id) + console.log(resp, "resprespresp") + + if (resp.success) { + toast.success("文章已删除") + table.refresh() + router.refresh() + } else { + toast.error(resp.message || "删除失败") + } + } catch (error) { + toast.error("删除失败") + console.error(error) + } + }, + [table, router], + ) + + return ( + +
+

文章管理

+ + + +
+ + + + {...table} + columns={[ + { header: "标题", accessorKey: "title" }, + { + header: "状态", + accessorKey: "status", + cell: ({ row }) => ( + + {row.original.status === 1 ? "已发布" : "草稿"} + + ), + }, + { + header: "更新时间", + accessorKey: "updated_at", + cell: ({ row }) => formatDate(row.original.updated_at), + }, + { + header: "操作", + id: "actions", + cell: ({ row }) => ( +
+ + 编辑 + + +
+ ), + }, + ]} + /> +
+
+ ) +} diff --git a/src/app/(root)/scopes.tsx b/src/app/(root)/scopes.tsx index 921ec1d..b8da21e 100644 --- a/src/app/(root)/scopes.tsx +++ b/src/app/(root)/scopes.tsx @@ -1,5 +1,6 @@ "use client" import { useSetAtom } from "jotai" +import { useEffect } from "react" import { scopesAtom } from "@/lib/stores/scopes" import type { Admin } from "@/models/admin" @@ -8,7 +9,9 @@ export default function SetScopes(props: { }) { const setScopes = useSetAtom(scopesAtom) - console.log("用户权限", props.admin.scopes) - setScopes(props.admin.scopes) + useEffect(() => { + setScopes(props.admin.scopes) + }, [props.admin.scopes, setScopes]) + return null } diff --git a/src/app/globals.css b/src/app/globals.css index d47e563..c12383f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -122,3 +122,127 @@ @apply bg-background text-foreground; } } + +.tiptap-editor { + .ProseMirror { + @apply w-full outline-none p-4; + + h1 { + @apply text-2xl font-bold mt-4 mb-2; + } + h2 { + @apply text-xl font-semibold mt-3 mb-2; + } + h3 { + @apply text-lg font-medium mt-2 mb-1; + } + p { + @apply my-2 leading-7; + } + ul { + @apply list-disc pl-6 my-2; + } + ol { + @apply list-decimal pl-6 my-2; + } + li { + @apply my-1; + } + blockquote { + @apply border-l-4 border-muted pl-4 italic text-muted-foreground my-3; + } + code { + @apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-rose-600; + } + pre { + @apply bg-slate-900 text-slate-100 rounded-lg p-4 my-3 overflow-x-auto; + code { + @apply bg-transparent p-0 text-sm text-inherit; + } + } + hr { + @apply my-4 border-border; + } + a { + @apply text-blue-600 underline; + } + strong { + @apply font-semibold; + } + em { + @apply italic; + } + s { + @apply line-through; + } + ul[data-type="taskList"] { + @apply list-none pl-0; + li { + @apply flex items-start gap-2 my-1; + label { + @apply mt-1; + } + div { + @apply flex-1; + } + } + } + table { + @apply border-collapse w-full my-3; + th, + td { + @apply border border-border px-3 py-2 text-sm; + } + th { + @apply bg-muted font-semibold text-left; + } + } + } +} + +/* Code block syntax highlighting */ +.tiptap-editor pre code .hljs-keyword, +.tiptap-editor pre code .hljs-selector-tag, +.tiptap-editor pre code .hljs-type { + color: #c792ea; +} + +.tiptap-editor pre code .hljs-string, +.tiptap-editor pre code .hljs-attr { + color: #c3e88d; +} + +.tiptap-editor pre code .hljs-number, +.tiptap-editor pre code .hljs-literal { + color: #f78c6c; +} + +.tiptap-editor pre code .hljs-comment { + color: #676e95; + font-style: italic; +} + +.tiptap-editor pre code .hljs-title, +.tiptap-editor pre code .hljs-function, +.tiptap-editor pre code .hljs-name { + color: #82aaff; +} + +.tiptap-editor pre code .hljs-built_in, +.tiptap-editor pre code .hljs-symbol { + color: #ffcb6b; +} + +.tiptap-editor pre code .hljs-variable, +.tiptap-editor pre code .hljs-params { + color: #f07178; +} + +.tiptap-editor pre code .hljs-meta, +.tiptap-editor pre code .hljs-tag { + color: #89ddff; +} + +.tiptap-editor pre code .hljs-attr { + color: #c3e88d; +} diff --git a/src/components/editor/editorExtensions.ts b/src/components/editor/editorExtensions.ts new file mode 100644 index 0000000..1915968 --- /dev/null +++ b/src/components/editor/editorExtensions.ts @@ -0,0 +1,60 @@ +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight" +import Image from "@tiptap/extension-image" +import Link from "@tiptap/extension-link" +import { Table } from "@tiptap/extension-table" +import { TableCell } from "@tiptap/extension-table-cell" +import { TableHeader } from "@tiptap/extension-table-header" +import { TableRow } from "@tiptap/extension-table-row" +import TaskItem from "@tiptap/extension-task-item" +import TaskList from "@tiptap/extension-task-list" +import TextAlign from "@tiptap/extension-text-align" +import Underline from "@tiptap/extension-underline" +import Youtube from "@tiptap/extension-youtube" +import StarterKit from "@tiptap/starter-kit" +import { common, createLowlight } from "lowlight" +import { MdxPasteHandler } from "./extensions/mdx-paste-handler" + +const lowlight = createLowlight(common) + +export const editorExtensions = [ + MdxPasteHandler, + StarterKit.configure({ + heading: { + levels: [1, 2, 3], + }, + codeBlock: false, + }), + Underline, + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: "text-blue-600 underline cursor-pointer", + }, + }), + CodeBlockLowlight.configure({ + lowlight, + }), + Image.configure({ + HTMLAttributes: { + class: "rounded-lg max-w-full", + }, + }), + TextAlign.configure({ + types: ["heading", "paragraph"], + }), + TaskList, + TaskItem.configure({ + nested: true, + }), + Youtube.configure({ + HTMLAttributes: { + class: "w-full aspect-video rounded-lg", + }, + }), + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, +] diff --git a/src/components/editor/editorMenuBar.tsx b/src/components/editor/editorMenuBar.tsx new file mode 100644 index 0000000..819b782 --- /dev/null +++ b/src/components/editor/editorMenuBar.tsx @@ -0,0 +1,453 @@ +"use client" + +import type { Editor } from "@tiptap/react" +import { + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Bold, + Code, + Code2, + FileCode2, + Heading1, + Heading2, + Heading3, + ImageIcon, + Italic, + LinkIcon, + List, + ListChecks, + ListOrdered, + Minus, + Quote, + Redo, + RemoveFormatting, + Strikethrough, + Table as TableIcon, + Trash2, + Underline as UnderlineIcon, + Undo, + UploadIcon, + YoutubeIcon, +} from "lucide-react" +import { useCallback, useRef, useState } from "react" +import { toast } from "sonner" +import { uploadArticleImage } from "@/actions/article" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" + +interface EditorMenuBarProps { + editor: Editor | null + onSave?: () => void + saving?: boolean + onImportMdx?: (content: string) => void +} + +export default function EditorMenuBar({ + editor, + onSave, + saving, + onImportMdx, +}: EditorMenuBarProps) { + const fileInputRef = useRef(null) + const imageInputRef = useRef(null) + const [imageUploading, setImageUploading] = useState(false) + + const handleImageUpload = useCallback( + async (e: React.ChangeEvent) => { + if (!editor) return + const file = e.target.files?.[0] + if (!file) return + + setImageUploading(true) + try { + const resp = await uploadArticleImage(file) + if (resp.success) { + editor.chain().focus().setImage({ src: resp.data.url }).run() + toast.success("图片上传成功") + } else { + toast.error(resp.message || "上传失败") + } + } catch { + toast.error("上传失败,网络错误") + } finally { + setImageUploading(false) + e.target.value = "" + } + }, + [editor], + ) + + const addImageByUrl = useCallback(() => { + if (!editor) return + const url = window.prompt("请输入图片链接") + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + }, [editor]) + + const addYoutube = useCallback(() => { + if (!editor) return + const url = window.prompt("请输入 YouTube 链接") + if (url) { + editor.chain().focus().setYoutubeVideo({ src: url }).run() + } + }, [editor]) + + const insertTable = useCallback(() => { + if (!editor) return + editor + .chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run() + }, [editor]) + + const deleteTable = useCallback(() => { + if (!editor) return + editor.chain().focus().deleteTable().run() + }, [editor]) + + const clearFormatting = useCallback(() => { + if (!editor) return + editor.chain().focus().clearNodes().unsetAllMarks().run() + }, [editor]) + + if (!editor) return null + + const ToolButton = ({ + onClick, + isActive, + icon: Icon, + label, + }: { + onClick: () => void + isActive: boolean + icon: React.ComponentType<{ className?: string }> + label: string + }) => ( + + + + + +

{label}

+
+
+ ) + + return ( +
+
+ + editor.chain().focus().toggleHeading({ level: 1 }).run() + } + isActive={editor.isActive("heading", { level: 1 })} + icon={Heading1} + label="标题 1" + /> + + editor.chain().focus().toggleHeading({ level: 2 }).run() + } + isActive={editor.isActive("heading", { level: 2 })} + icon={Heading2} + label="标题 2" + /> + + editor.chain().focus().toggleHeading({ level: 3 }).run() + } + isActive={editor.isActive("heading", { level: 3 })} + icon={Heading3} + label="标题 3" + /> +
+ +
+ +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + icon={Bold} + label="加粗" + /> + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + icon={Italic} + label="斜体" + /> + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive("underline")} + icon={UnderlineIcon} + label="下划线" + /> + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + icon={Strikethrough} + label="删除线" + /> + editor.chain().focus().toggleCode().run()} + isActive={editor.isActive("code")} + icon={Code} + label="行内代码" + /> + +
+ +
+ +
+ editor.chain().focus().setTextAlign("left").run()} + isActive={editor.isActive({ textAlign: "left" })} + icon={AlignLeft} + label="左对齐" + /> + editor.chain().focus().setTextAlign("center").run()} + isActive={editor.isActive({ textAlign: "center" })} + icon={AlignCenter} + label="居中对齐" + /> + editor.chain().focus().setTextAlign("right").run()} + isActive={editor.isActive({ textAlign: "right" })} + icon={AlignRight} + label="右对齐" + /> + editor.chain().focus().setTextAlign("justify").run()} + isActive={editor.isActive({ textAlign: "justify" })} + icon={AlignJustify} + label="两端对齐" + /> +
+ +
+ +
+ editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive("bulletList")} + icon={List} + label="无序列表" + /> + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive("orderedList")} + icon={ListOrdered} + label="有序列表" + /> + editor.chain().focus().toggleTaskList().run()} + isActive={editor.isActive("taskList")} + icon={ListChecks} + label="任务列表" + /> + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive("blockquote")} + icon={Quote} + label="引用" + /> + editor.chain().focus().toggleCodeBlock().run()} + isActive={editor.isActive("codeBlock")} + icon={Code2} + label="代码块" + /> +
+ +
+ +
+ editor.chain().focus().setHorizontalRule().run()} + isActive={false} + icon={Minus} + label="分割线" + /> + + + + + + imageInputRef.current?.click()} + disabled={imageUploading} + > + + 本地上传 + + + + 输入链接 + + + + + + {editor.isActive("table") && ( + + )} +
+ +
+ +
+ editor.chain().focus().undo().run()} + isActive={false} + icon={Undo} + label="撤销" + /> + editor.chain().focus().redo().run()} + isActive={false} + icon={Redo} + label="重做" + /> +
+ + {onImportMdx && ( + <> +
+
+ + + + + +

导入 MDX

+
+
+ { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + const text = reader.result as string + if (text && onImportMdx) { + onImportMdx(text) + } + } + reader.readAsText(file) + e.target.value = "" + }} + /> + + + + + +

打开 MDX 文件

+
+
+
+ + )} + + + + {onSave && ( + <> +
+
+ +
+ + )} +
+ ) +} diff --git a/src/components/editor/editorSidebar.tsx b/src/components/editor/editorSidebar.tsx new file mode 100644 index 0000000..b8a2e03 --- /dev/null +++ b/src/components/editor/editorSidebar.tsx @@ -0,0 +1,117 @@ +"use client" + +import { Globe, ImageIcon } from "lucide-react" +import type { ReactNode } from "react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { ArticleGroup } from "@/models/article-group" + +interface EditorSidebarProps { + title: string + coverImage: string + groups: ArticleGroup[] + groupId: string + onTitleChange: (title: string) => void + onCoverImageChange: (url: string) => void + onGroupChange: (groupId: string) => void +} + +function SidebarSection({ + icon: Icon, + title, + children, +}: { + icon: React.ComponentType<{ className?: string }> + title: string + children: ReactNode +}) { + return ( +
+
+ + {title} +
+ {children} +
+ ) +} + +export default function EditorSidebar({ + coverImage, + groups, + groupId, + onCoverImageChange, + onGroupChange, +}: EditorSidebarProps) { + return ( +
+ +
+
+ + +
+
+
+ + +
+
+ + onCoverImageChange(e.target.value)} + placeholder="https://..." + className="mt-1 h-8 text-sm" + /> +
+ {coverImage && ( +
+ )} +
+ + + +
+
+ + +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/editor/extensions/mdx-paste-handler.ts b/src/components/editor/extensions/mdx-paste-handler.ts new file mode 100644 index 0000000..78fe587 --- /dev/null +++ b/src/components/editor/extensions/mdx-paste-handler.ts @@ -0,0 +1,42 @@ +import { Extension } from "@tiptap/core" +import { DOMParser } from "@tiptap/pm/model" +import { Plugin, PluginKey } from "@tiptap/pm/state" +import { isMdxLike, mdxToHtml } from "@/lib/mdx-converter" + +export const MdxPasteHandler = Extension.create({ + name: "mdxPasteHandler", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("mdxPasteHandler"), + props: { + handlePaste: (_view, event) => { + const text = event.clipboardData?.getData("text/plain") + if (!text || !isMdxLike(text)) return false + + const html = mdxToHtml(text) + if (!html) return false + + event.preventDefault() + const { view } = this.editor + const { state, dispatch } = view + + const dom = document.createElement("div") + dom.innerHTML = html + const slice = DOMParser.fromSchema(state.schema).parseSlice(dom, { + preserveWhitespace: false, + }) + + if (slice.content.size > 0) { + const tr = state.tr.replaceSelection(slice) + dispatch(tr) + return true + } + return false + }, + }, + }), + ] + }, +}) diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts new file mode 100644 index 0000000..b163bb8 --- /dev/null +++ b/src/components/editor/index.ts @@ -0,0 +1,5 @@ +export { editorExtensions } from "./editorExtensions" +export { default as EditorMenuBar } from "./editorMenuBar" +export { default as EditorSidebar } from "./editorSidebar" +export { MdxPasteHandler } from "./extensions/mdx-paste-handler" +export { default as RichTextEditor } from "./richTextEditor" diff --git a/src/components/editor/richTextEditor.tsx b/src/components/editor/richTextEditor.tsx new file mode 100644 index 0000000..71f6f18 --- /dev/null +++ b/src/components/editor/richTextEditor.tsx @@ -0,0 +1,130 @@ +"use client" + +import { EditorContent, useEditor } from "@tiptap/react" +import { useCallback, useEffect, useState } from "react" +import { mdxToHtml } from "@/lib/mdx-converter" +import type { ArticleGroup } from "@/models/article-group" +import { editorExtensions } from "./editorExtensions" +import EditorMenuBar from "./editorMenuBar" +import EditorSidebar from "./editorSidebar" + +interface RichTextEditorProps { + content?: string + title?: string + coverImage?: string + groups: ArticleGroup[] + groupId: string + onCoverImageChange?: (url: string) => void + onGroupChange: (groupId: string) => void + onSave: (data: { title: string; content: string }) => Promise +} + +export default function RichTextEditor({ + content = "", + title = "", + coverImage = "", + groups, + groupId, + onCoverImageChange, + onGroupChange, + onSave, +}: RichTextEditorProps) { + const [saving, setSaving] = useState(false) + const [docTitle, setDocTitle] = useState(title) + const [sectionTitle, setSectionTitle] = useState("") + const [docCoverImage, setDocCoverImage] = useState(coverImage) + + const editor = useEditor({ + extensions: editorExtensions, + content, + immediatelyRender: false, + editorProps: { + attributes: { + class: "focus:outline-none w-full p-4", + }, + }, + }) + + useEffect(() => { + setDocTitle(title) + }, [title]) + + useEffect(() => { + setDocCoverImage(coverImage) + }, [coverImage]) + + useEffect(() => { + if (!editor || editor.isDestroyed) return + const current = editor.getHTML() + const target = content || "" + if (target && current !== target) { + editor.commands.setContent(target) + } + }, [content, editor]) + + const handleImportMdx = useCallback( + (mdxContent: string) => { + if (!editor || editor.isDestroyed) return + const html = mdxToHtml(mdxContent) + if (html) { + editor.commands.setContent(html) + } + }, + [editor], + ) + + const handleSave = useCallback(async () => { + if (!editor || editor.isDestroyed) return + setSaving(true) + try { + await onSave({ + title: docTitle, + content: editor.getHTML(), + }) + } finally { + setSaving(false) + } + }, [editor, docTitle, onSave]) + + return ( +
+ {})} + onGroupChange={onGroupChange} + /> + +
+ {/* 固定标题区 */} +
+ setDocTitle(e.target.value)} + placeholder="输入文档标题..." + className="w-full text-xl font-semibold bg-transparent border-none outline-none placeholder:text-muted-foreground" + /> +
+ + {/* 固定工具栏 */} +
+ +
+ + {/* 可滚动的内容区 */} +
+ +
+
+
+ ) +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ae1fcf6..48a60dd 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,8 +1,8 @@ "use client" -import * as React from "react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" +import type * as React from "react" import { cn } from "@/lib/utils" @@ -43,7 +43,7 @@ function DropdownMenuContent({ sideOffset={sideOffset} className={cn( "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", - className + className, )} {...props} /> @@ -75,7 +75,7 @@ function DropdownMenuItem({ data-variant={variant} className={cn( "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", - className + className, )} {...props} /> @@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({ data-slot="dropdown-menu-checkbox-item" className={cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} checked={checked} {...props} @@ -129,7 +129,7 @@ function DropdownMenuRadioItem({ data-slot="dropdown-menu-radio-item" className={cn( "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} {...props} > @@ -156,7 +156,7 @@ function DropdownMenuLabel({ data-inset={inset} className={cn( "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", - className + className, )} {...props} /> @@ -185,7 +185,7 @@ function DropdownMenuShortcut({ data-slot="dropdown-menu-shortcut" className={cn( "ml-auto text-xs tracking-widest text-muted-foreground", - className + className, )} {...props} /> @@ -212,7 +212,7 @@ function DropdownMenuSubTrigger({ data-inset={inset} className={cn( "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", - className + className, )} {...props} > @@ -231,7 +231,7 @@ function DropdownMenuSubContent({ data-slot="dropdown-menu-sub-content" className={cn( "z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", - className + className, )} {...props} /> diff --git a/src/lib/mdx-converter.ts b/src/lib/mdx-converter.ts new file mode 100644 index 0000000..f763630 --- /dev/null +++ b/src/lib/mdx-converter.ts @@ -0,0 +1,92 @@ +import { marked } from "marked" + +marked.setOptions({ + gfm: true, + breaks: false, +}) + +function stripFrontmatter(content: string): { + frontmatter: Record + body: string +} { + const frontmatter: Record = {} + if (!content.startsWith("---")) { + return { frontmatter, body: content } + } + const end = content.indexOf("---", 3) + if (end === -1) { + return { frontmatter, body: content } + } + const fm = content.substring(3, end).trim() + const body = content.substring(end + 3).trim() + for (const line of fm.split("\n")) { + const colonIdx = line.indexOf(":") + if (colonIdx === -1) continue + const key = line.substring(0, colonIdx).trim() + const value = line.substring(colonIdx + 1).trim() + if (key) { + frontmatter[key] = value + } + } + return { frontmatter, body } +} + +function transformMdxJsx(content: string): string { + let result = content + result = result.replace( + /<([A-Z][a-zA-Z0-9]*)\b[^>]*\/\s*>/g, + (_match, tagName) => { + return `<${tagName} />` + }, + ) + result = result.replace( + /<([A-Z][a-zA-Z0-9]*)\b([^>]*)>([\s\S]*?)<\/\1>/g, + (_match, tagName, attrs, inner) => { + const encodedAttrs = attrs + .replace(/&/g, "&") + .replace(//g, ">") + const encodedInner = inner + .replace(/&/g, "&") + .replace(//g, ">") + return `
<${tagName}${encodedAttrs}>\n${encodedInner}\n</${tagName}>
` + }, + ) + return result +} + +export function mdxToHtml(mdxContent: string): string { + if (!mdxContent || typeof mdxContent !== "string") return "" + + const { body } = stripFrontmatter(mdxContent) + const cleaned = transformMdxJsx(body) + + const html = marked.parse(cleaned) as string | Promise + + if (typeof html === "string") { + return html + } + + return "" +} + +export function isMdxLike(text: string): boolean { + if (!text || typeof text !== "string") return false + const patterns = [ + /^#{1,6}\s/m, + /^>\s/m, + /^[-*+]\s/m, + /^```/m, + /^---\s*$/m, + /\[.+\]\(.+\)/, + /\*\*.*\*\*/, + /__.*__/, + /^`[^`]+`$/m, + /^<[A-Z][a-zA-Z]*[\s>]/m, + /^\d+\.\s/m, + /^\|\s.*\|\s*$/m, + /^-\s\[[ x]\]\s/m, + ] + return patterns.some(p => p.test(text)) +} diff --git a/src/models/article-group.ts b/src/models/article-group.ts new file mode 100644 index 0000000..528254e --- /dev/null +++ b/src/models/article-group.ts @@ -0,0 +1,8 @@ +import type { Model } from "./base/model" + +export type ArticleGroup = Model & { + code: string + name: string + sort: number + status: number +} diff --git a/src/models/article.ts b/src/models/article.ts new file mode 100644 index 0000000..765f884 --- /dev/null +++ b/src/models/article.ts @@ -0,0 +1,20 @@ +import type { Model } from "./base/model" + +export type Article = Model & { + title: string + section_title: string + content: string + group_id: number + description?: string + cover_image?: string + sort: number + status: number +} + +export type UploadImageResult = { + url: string + path: string + original_name: string + size: number + mime_type: string +}