From 7135e34699b227d615b64ddbd0f4f805309f48eb Mon Sep 17 00:00:00 2001 From: shrt Date: Tue, 11 Mar 2025 15:51:31 +0100 Subject: [PATCH] . --- package.json | 9 + pnpm-lock.yaml | 202 +++++++++++++++++- .../_components/category/category-card.tsx | 3 +- .../[slug]/{edit => bearbeiten}/page.tsx | 4 +- src/app/(PAGES)/artikel/[slug]/page.tsx | 31 ++- src/app/(PAGES)/kategorie/[name]/page.tsx | 7 +- src/app/(PAGES)/page.tsx | 2 +- src/app/admin/_components/admin-sidebar.tsx | 2 +- .../article/article-card.tsx | 3 +- .../article/{editor => }/article-form.tsx | 33 ++- .../article/create-article-dialog.tsx | 12 +- src/components/article/render-article.tsx | 8 + src/components/bread-navigator.tsx | 8 +- src/components/layout/app-sidebar.tsx | 88 ++++---- src/components/layout/navbar.tsx | 5 +- src/components/layout/sidebar-link.tsx | 10 +- src/components/text-editor/editor.tsx | 0 .../text-editor/extentions/block-drag.tsx | 92 ++++++++ .../extentions/index.ts} | 32 ++- .../editor.tsx => text-editor/index.tsx} | 12 +- .../editor => text-editor}/menu-bar.tsx | 0 .../text-editor/plugins/draggable-node.tsx | 34 +++ .../editor => text-editor}/styles.css | 21 ++ src/components/ui/collapsible.tsx | 11 + src/{ => config}/app.config.ts | 0 src/config/app.routes.ts | 43 ++++ src/config/index.ts | 2 + src/lib/utils.ts | 23 +- src/lib/validation/zod/article.ts | 7 +- src/server/actions/article.ts | 15 +- src/server/api/root.ts | 3 +- src/server/api/routers/app.ts | 16 ++ src/server/api/routers/article.ts | 13 +- src/server/db/schema.ts | 12 +- 34 files changed, 633 insertions(+), 130 deletions(-) rename src/app/(PAGES)/artikel/[slug]/{edit => bearbeiten}/page.tsx (83%) rename src/{app/(PAGES)/_components => components}/article/article-card.tsx (80%) rename src/components/article/{editor => }/article-form.tsx (91%) rename src/{app/(PAGES)/_components => components}/article/create-article-dialog.tsx (87%) create mode 100644 src/components/article/render-article.tsx create mode 100644 src/components/text-editor/editor.tsx create mode 100644 src/components/text-editor/extentions/block-drag.tsx rename src/components/{article/editor/extentions.ts => text-editor/extentions/index.ts} (55%) rename src/components/{article/editor/editor.tsx => text-editor/index.tsx} (69%) rename src/components/{article/editor => text-editor}/menu-bar.tsx (100%) create mode 100644 src/components/text-editor/plugins/draggable-node.tsx rename src/components/{article/editor => text-editor}/styles.css (83%) create mode 100644 src/components/ui/collapsible.tsx rename src/{ => config}/app.config.ts (100%) create mode 100644 src/config/app.routes.ts create mode 100644 src/config/index.ts create mode 100644 src/server/api/routers/app.ts diff --git a/package.json b/package.json index 1402ddf..dd4d451 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,16 @@ }, "dependencies": { "@auth/drizzle-adapter": "^1.7.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^4.1.3", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -37,6 +42,7 @@ "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/react-query": "^5.50.0", "@tanstack/react-table": "^8.21.2", + "@tiptap/core": "^2.11.5", "@tiptap/extension-color": "^2.11.5", "@tiptap/extension-image": "^2.11.5", "@tiptap/extension-list-item": "^2.11.5", @@ -58,8 +64,10 @@ "next-auth": "5.0.0-beta.25", "next-themes": "^0.4.4", "postgres": "^3.4.4", + "prosemirror-state": "^1.4.3", "react": "^18.3.1", "react-colorful": "^5.6.1", + "react-dnd": "^16.0.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", "react-textarea-autosize": "^8.5.7", @@ -68,6 +76,7 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-global-drag-handle": "^0.1.18", "zod": "^3.24.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36f86e2..ac33a1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,18 @@ importers: '@auth/drizzle-adapter': specifier: ^1.7.2 version: 1.8.0 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) '@hookform/resolvers': specifier: ^4.1.3 version: 4.1.3(react-hook-form@7.54.2(react@18.3.1)) @@ -26,6 +38,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -59,6 +74,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.2 version: 8.21.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tiptap/core': + specifier: ^2.11.5 + version: 2.11.5(@tiptap/pm@2.11.5) '@tiptap/extension-color': specifier: ^2.11.5 version: 2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))(@tiptap/extension-text-style@2.11.5(@tiptap/core@2.11.5(@tiptap/pm@2.11.5))) @@ -122,12 +140,18 @@ importers: postgres: specifier: ^3.4.4 version: 3.4.5 + prosemirror-state: + specifier: ^1.4.3 + version: 1.4.3 react: specifier: ^18.3.1 version: 18.3.1 react-colorful: specifier: ^5.6.1 version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dnd: + specifier: ^16.0.1 + version: 16.0.1(@types/node@20.17.23)(@types/react@18.3.18)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -152,6 +176,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.23)(typescript@5.8.2))) + tiptap-extension-global-drag-handle: + specifier: ^0.1.18 + version: 0.1.18 zod: specifier: ^3.24.2 version: 3.24.2 @@ -268,6 +295,34 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1031,6 +1086,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.3': + resolution: {integrity: sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==} + 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 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.2': resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} peerDependencies: @@ -1505,6 +1573,15 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@react-dnd/asap@5.0.2': + resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==} + + '@react-dnd/invariant@4.0.2': + resolution: {integrity: sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==} + + '@react-dnd/shallowequal@4.0.2': + resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -2115,6 +2192,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dnd-core@16.0.1: + resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2572,6 +2652,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3220,6 +3303,21 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + react-dnd@16.0.1: + resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} + peerDependencies: + '@types/hoist-non-react-statics': '>= 3.3.1' + '@types/node': '>= 12' + '@types/react': '>= 16' + react: '>= 16.14' + peerDependenciesMeta: + '@types/hoist-non-react-statics': + optional: true + '@types/node': + optional: true + '@types/react': + optional: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -3291,6 +3389,9 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3542,6 +3643,9 @@ packages: tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tiptap-extension-global-drag-handle@0.1.18: + resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3782,6 +3886,38 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/runtime@1.3.1': @@ -4288,6 +4424,22 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collapsible@1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collection@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) @@ -4740,6 +4892,12 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@react-dnd/asap@5.0.2': {} + + '@react-dnd/invariant@4.0.2': {} + + '@react-dnd/shallowequal@4.0.2': {} + '@remirror/core-constants@3.0.0': {} '@rtsao/scc@1.1.0': {} @@ -5391,6 +5549,12 @@ snapshots: dlv@1.1.3: {} + dnd-core@16.0.1: + dependencies: + '@react-dnd/asap': 5.0.2 + '@react-dnd/invariant': 4.0.2 + redux: 4.2.1 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -5629,8 +5793,8 @@ snapshots: '@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -5649,7 +5813,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -5660,18 +5824,18 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -5679,7 +5843,7 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -5690,7 +5854,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -5998,6 +6162,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -6608,6 +6776,18 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-dnd@16.0.1(@types/node@20.17.23)(@types/react@18.3.18)(react@18.3.1): + dependencies: + '@react-dnd/invariant': 4.0.2 + '@react-dnd/shallowequal': 4.0.2 + dnd-core: 16.0.1 + fast-deep-equal: 3.1.3 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + optionalDependencies: + '@types/node': 20.17.23 + '@types/react': 18.3.18 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -6679,6 +6859,10 @@ snapshots: dependencies: picomatch: 2.3.1 + redux@4.2.1: + dependencies: + '@babel/runtime': 7.26.9 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7019,6 +7203,8 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 + tiptap-extension-global-drag-handle@0.1.18: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/src/app/(PAGES)/_components/category/category-card.tsx b/src/app/(PAGES)/_components/category/category-card.tsx index 545bbfc..92d2632 100644 --- a/src/app/(PAGES)/_components/category/category-card.tsx +++ b/src/app/(PAGES)/_components/category/category-card.tsx @@ -1,3 +1,4 @@ +import { appRoutes } from "@/config"; import { Category } from "@/server/db/schema"; import Link from "next/link"; import React from "react"; @@ -8,7 +9,7 @@ function CategoryCard({ createdAt, }: Pick) { return ( - +
{name}
); diff --git a/src/app/(PAGES)/artikel/[slug]/edit/page.tsx b/src/app/(PAGES)/artikel/[slug]/bearbeiten/page.tsx similarity index 83% rename from src/app/(PAGES)/artikel/[slug]/edit/page.tsx rename to src/app/(PAGES)/artikel/[slug]/bearbeiten/page.tsx index b1d6e40..5223f01 100644 --- a/src/app/(PAGES)/artikel/[slug]/edit/page.tsx +++ b/src/app/(PAGES)/artikel/[slug]/bearbeiten/page.tsx @@ -3,7 +3,7 @@ import { auth } from "@/server/auth"; import { api } from "@/trpc/server"; import { notFound } from "next/navigation"; import React from "react"; -import Editor from "@/components/article/editor/article-form"; +import ArticleEditor from "@/components/article/article-form"; async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -13,7 +13,7 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) { : false; const article = await api.article.get({ slug: slug }); if (!article || !isEditor) return notFound(); - return ; + return ; } export default Page; diff --git a/src/app/(PAGES)/artikel/[slug]/page.tsx b/src/app/(PAGES)/artikel/[slug]/page.tsx index fc6efb1..ec240cb 100644 --- a/src/app/(PAGES)/artikel/[slug]/page.tsx +++ b/src/app/(PAGES)/artikel/[slug]/page.tsx @@ -1,4 +1,7 @@ +import RenderArticle from "@/components/article/render-article"; +import BreadNavigator from "@/components/bread-navigator"; import { Button } from "@/components/ui/button"; +import { appRoutes } from "@/config"; import { hasPermission, Role } from "@/lib/validation/permissions"; import { auth } from "@/server/auth"; import { api } from "@/trpc/server"; @@ -16,13 +19,28 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) { ? hasPermission(session.user.role, Role.EDITOR) : false; return ( -
+
-

{article.title}

+
+ +
{isEditor && ( -
+
)}
+

{article.title}

+ + {article?.content?.length ? ( + + ) : null}
); } diff --git a/src/app/(PAGES)/kategorie/[name]/page.tsx b/src/app/(PAGES)/kategorie/[name]/page.tsx index 8b5ed37..bc39192 100644 --- a/src/app/(PAGES)/kategorie/[name]/page.tsx +++ b/src/app/(PAGES)/kategorie/[name]/page.tsx @@ -6,7 +6,8 @@ import { api } from "@/trpc/server"; import { Edit } from "lucide-react"; import { notFound } from "next/navigation"; import React from "react"; -import ArticleCard from "../../_components/article/article-card"; +import ArticleCard from "../../../../components/article/article-card"; +import { appRoutes } from "@/config"; async function Page({ params }: { params: Promise<{ name: string }> }) { const { name } = await params; @@ -22,10 +23,10 @@ async function Page({ params }: { params: Promise<{ name: string }> }) {
diff --git a/src/app/(PAGES)/page.tsx b/src/app/(PAGES)/page.tsx index 85dc710..bf88e12 100644 --- a/src/app/(PAGES)/page.tsx +++ b/src/app/(PAGES)/page.tsx @@ -2,7 +2,7 @@ import { auth } from "@/server/auth"; import { api } from "@/trpc/server"; import Link from "next/link"; import GlobalStats from "./_components/global-stats"; -import ArticleCard from "./_components/article/article-card"; +import ArticleCard from "../../components/article/article-card"; import CategoryCard from "./_components/category/category-card"; export default async function Home() { diff --git a/src/app/admin/_components/admin-sidebar.tsx b/src/app/admin/_components/admin-sidebar.tsx index dac20dd..17723d3 100644 --- a/src/app/admin/_components/admin-sidebar.tsx +++ b/src/app/admin/_components/admin-sidebar.tsx @@ -14,7 +14,7 @@ import Link from "next/link"; import SidebarLink from "@/components/layout/sidebar-link"; import { Button } from "@/components/ui/button"; import { LogOutIcon } from "lucide-react"; -import { appConfig } from "@/app.config"; +import { appConfig } from "@/config/app.config"; const data = { navMain: [ diff --git a/src/app/(PAGES)/_components/article/article-card.tsx b/src/components/article/article-card.tsx similarity index 80% rename from src/app/(PAGES)/_components/article/article-card.tsx rename to src/components/article/article-card.tsx index c81a73a..bc7d7b3 100644 --- a/src/app/(PAGES)/_components/article/article-card.tsx +++ b/src/components/article/article-card.tsx @@ -1,3 +1,4 @@ +import { appRoutes } from "@/config"; import { Article } from "@/server/db/schema"; import Link from "next/link"; import React from "react"; @@ -8,7 +9,7 @@ function ArticleCard({ createdAt, }: Pick) { return ( - +
{title}
); diff --git a/src/components/article/editor/article-form.tsx b/src/components/article/article-form.tsx similarity index 91% rename from src/components/article/editor/article-form.tsx rename to src/components/article/article-form.tsx index 8a6d53d..b0a4051 100644 --- a/src/components/article/editor/article-form.tsx +++ b/src/components/article/article-form.tsx @@ -19,41 +19,32 @@ import { import { articleSchema } from "@/lib/validation/zod/article"; import { cn, debounce } from "@/lib/utils"; import { updateArticle } from "@/server/actions/article"; -import Editor from "./editor"; +import Editor from "../text-editor"; import { Badge } from "@/components/ui/badge"; import Link from "next/link"; import CategorySelect from "@/components/category/category-select"; -import { - Check, - CheckCircle, - MessageCircleWarning, - XCircle, -} from "lucide-react"; -import { Switch } from "@/components/ui/switch"; -import PublishArticleAlertDialog from "../publish-article-alert-dialog"; +import { CheckCircle, XCircle } from "lucide-react"; +import PublishArticleAlertDialog from "./publish-article-alert-dialog"; import { Label } from "@/components/ui/label"; export default ({ server_article }: { server_article: Article }) => { - const [selectedCategory, setSelectedCategory] = - React.useState(null); const [loading, setLoading] = React.useState(false); const form = useForm>({ resolver: zodResolver(articleSchema), defaultValues: { title: server_article?.title ?? "", + slug: server_article?.slug, published: server_article?.published ?? false, categoryId: server_article?.categoryId ?? "", authorId: server_article?.authorId ?? "", - content: - server_article?.content ?? - `

- Hey bearbeite mich! -

`, + content: server_article?.content, }, }); // 2. Define a submit handler. async function onSubmit(values: z.infer) { + console.log("Content before save", values.content); + setLoading(true); await updateArticle(values, server_article.id); setLoading(false); @@ -100,11 +91,17 @@ export default ({ server_article }: { server_article: Article }) => { { - field.onChange(value.editor.getHTML()); + const newContent = value.editor.getHTML(); + console.log( + "Content :: form", + JSON.stringify(newContent), + ); + field.onChange(newContent); debouncedSubmit(); }, }} diff --git a/src/app/(PAGES)/_components/article/create-article-dialog.tsx b/src/components/article/create-article-dialog.tsx similarity index 87% rename from src/app/(PAGES)/_components/article/create-article-dialog.tsx rename to src/components/article/create-article-dialog.tsx index aed4b63..a00079d 100644 --- a/src/app/(PAGES)/_components/article/create-article-dialog.tsx +++ b/src/components/article/create-article-dialog.tsx @@ -23,24 +23,20 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { articleSchema } from "@/lib/validation/zod/article"; +import { createArticleSchema } from "@/lib/validation/zod/article"; import { createArticle } from "@/server/actions/article"; -const formSchema = articleSchema.pick({ - title: true, -}); - function CreateArticleDialog() { const [open, setOpen] = React.useState(false); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(createArticleSchema), defaultValues: { title: "", }, }); // 2. Define a submit handler. - async function onSubmit(values: z.infer) { + async function onSubmit(values: z.infer) { setOpen(false); await createArticle(values); } diff --git a/src/components/article/render-article.tsx b/src/components/article/render-article.tsx new file mode 100644 index 0000000..ace1202 --- /dev/null +++ b/src/components/article/render-article.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import Editor from "../text-editor"; + +function RenderArticle({ content }: { content: string }) { + return ; +} + +export default RenderArticle; diff --git a/src/components/bread-navigator.tsx b/src/components/bread-navigator.tsx index eb3c845..0c30841 100644 --- a/src/components/bread-navigator.tsx +++ b/src/components/bread-navigator.tsx @@ -13,6 +13,8 @@ function BreadNavigator({ }: { links: { label: string; href: string }[]; }) { + const labelClass = + "w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block"; return ( @@ -20,8 +22,8 @@ function BreadNavigator({ if (idx < links.length - 1) return ( - - + + {label} @@ -29,7 +31,7 @@ function BreadNavigator({ ); return ( - + {label} ); diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 002cca3..21bca26 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -5,23 +5,31 @@ import { SidebarContent, SidebarFooter, SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, } from "@/components/ui/sidebar"; import Link from "next/link"; import { Separator } from "../ui/separator"; -import SidebarLink from "./sidebar-link"; -import { Button } from "../ui/button"; -import { HomeIcon } from "lucide-react"; +import { Minus, Plus } from "lucide-react"; import { api } from "@/trpc/server"; -import { appConfig } from "@/app.config"; +import { appConfig } from "@/config/app.config"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { appRoutes } from "@/config"; +import SidebarLink from "./sidebar-link"; export async function AppSidebar({ ...props }: React.ComponentProps) { - const articles = await api.article.getAllPreviews(); + const sidebarContent = await api.app.getSidebarContent(); return ( @@ -31,39 +39,39 @@ export async function AppSidebar({ - - - {/* We create a SidebarGroup for each parent. */} - {/* {data.navMain.map((item) => ( - - {item.title} - - - {item.items.map((item) => ( - - ))} - - - - ))} */} - - Artikel - - - {articles?.map((article) => ( - - ))} - - + + {sidebarContent?.map((category, index) => ( + + + + + {category.name}{" "} + + + + + {category?.articles?.length ? ( + + + {category.articles.map((article) => ( + + + + ))} + + + ) : null} + + + ))} + diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 1d0540d..ce8f028 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -10,8 +10,9 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import CreateArticleDialog from "@/app/(PAGES)/_components/article/create-article-dialog"; +import CreateArticleDialog from "@/components/article/create-article-dialog"; import CreateCategoryDialog from "@/app/(PAGES)/_components/category/create-category-dialog"; +import { appRoutes } from "@/config"; async function Navbar() { const session = await auth(); @@ -44,7 +45,7 @@ async function Navbar() { )} {isAdmin && ( )} {session ? ( diff --git a/src/components/layout/sidebar-link.tsx b/src/components/layout/sidebar-link.tsx index 07349bd..1fda038 100644 --- a/src/components/layout/sidebar-link.tsx +++ b/src/components/layout/sidebar-link.tsx @@ -2,17 +2,15 @@ import React from "react"; import { usePathname } from "next/navigation"; -import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; +import { SidebarMenuSubButton } from "@/components/ui/sidebar"; import Link from "next/link"; function SidebarLink({ url, title }: { url: string; title: string }) { const isActive = usePathname() === url; return ( - - - {title} - - + + {title} + ); } diff --git a/src/components/text-editor/editor.tsx b/src/components/text-editor/editor.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/text-editor/extentions/block-drag.tsx b/src/components/text-editor/extentions/block-drag.tsx new file mode 100644 index 0000000..17c8b44 --- /dev/null +++ b/src/components/text-editor/extentions/block-drag.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; +import { useDrag, useDrop } from "react-dnd"; + +const DRAG_TYPE = "TIPTAP_BLOCK"; + +export const DraggableBlockView = (props) => { + const { node, getPos, editor } = props; + const [showDragHandle, setShowDragHandle] = useState(false); + + // Get node position and ID + const nodePos = getPos(); + const nodeId = `${node.type.name}-${nodePos}`; + + // Set up drag + const [{ isDragging }, drag, dragPreview] = useDrag({ + type: DRAG_TYPE, + item: { id: nodeId, pos: nodePos, type: node.type.name }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + begin: () => { + // Select the block on drag start + editor.commands.setNodeSelection(nodePos); + return { id: nodeId, pos: nodePos, type: node.type.name }; + }, + }); + + // Set up drop + const [{ isOver, canDrop }, drop] = useDrop({ + accept: DRAG_TYPE, + drop: (item) => { + if (item.pos !== nodePos) { + // Move the dragged node to this position + const tr = editor.state.tr; + const sourcePos = item.pos; + const targetPos = nodePos; + + // Logic to move nodes in the document + // This is the complex part that requires careful handling of ProseMirror positions + const sourceNode = tr.doc.nodeAt(sourcePos); + if (sourceNode) { + tr.delete(sourcePos, sourcePos + sourceNode.nodeSize); + const newTargetPos = + targetPos > sourcePos ? targetPos - sourceNode.nodeSize : targetPos; + tr.insert(newTargetPos, sourceNode); + editor.view.dispatch(tr); + } + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + + // Reference for the drag preview (the whole block) + const dragRef = useRef(null); + + return ( + setShowDragHandle(true)} + onMouseLeave={() => setShowDragHandle(false)} + > + {showDragHandle && ( +
+ ⋮⋮ +
+ )} +
+ +
+
+ ); +}; diff --git a/src/components/article/editor/extentions.ts b/src/components/text-editor/extentions/index.ts similarity index 55% rename from src/components/article/editor/extentions.ts rename to src/components/text-editor/extentions/index.ts index a651a14..23a9209 100644 --- a/src/components/article/editor/extentions.ts +++ b/src/components/text-editor/extentions/index.ts @@ -3,6 +3,23 @@ import ListItem from "@tiptap/extension-list-item"; import TextStyle from "@tiptap/extension-text-style"; import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; +import GlobalDragHandle from "tiptap-extension-global-drag-handle"; +import { Extension } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { DraggableBlockView } from "./block-drag"; + +const DraggableBlocks = Extension.create({ + name: "draggableBlocks", + + addProseMirrorPlugins() { + return []; + }, + + // Apply this nodeview to all block nodes + addNodeView() { + return ReactNodeViewRenderer(DraggableBlockView); + }, +}); export const extensions = [ Color.configure({ types: [TextStyle.name, ListItem.name] }), @@ -11,7 +28,9 @@ export const extensions = [ StarterKit.configure({ code: false, codeBlock: false, - horizontalRule: {}, + heading: { + levels: [1, 2, 3, 4, 5, 6], + }, bulletList: { keepMarks: true, keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help @@ -21,5 +40,16 @@ export const extensions = [ keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help }, }), + GlobalDragHandle, Image, + DraggableBlocks.configure({ + types: [ + "paragraph", + "heading", + "blockquote", + "codeBlock", + "bulletList", + "orderedList", + ], + }), ]; diff --git a/src/components/article/editor/editor.tsx b/src/components/text-editor/index.tsx similarity index 69% rename from src/components/article/editor/editor.tsx rename to src/components/text-editor/index.tsx index 2cd884a..0e433f4 100644 --- a/src/components/article/editor/editor.tsx +++ b/src/components/text-editor/index.tsx @@ -5,6 +5,7 @@ import React from "react"; import { EditorProvider, EditorProviderProps } from "@tiptap/react"; import { MenuBar } from "./menu-bar"; import { extensions } from "./extentions"; +import { cn } from "@/lib/utils"; function Editor({ editorProviderProps, @@ -14,15 +15,22 @@ function Editor({ readOnly?: boolean; }) { return ( -
+
} {...editorProviderProps} + editable={!readOnly} /> + + {JSON.stringify(editorProviderProps.content)}
); } - export default Editor; diff --git a/src/components/article/editor/menu-bar.tsx b/src/components/text-editor/menu-bar.tsx similarity index 100% rename from src/components/article/editor/menu-bar.tsx rename to src/components/text-editor/menu-bar.tsx diff --git a/src/components/text-editor/plugins/draggable-node.tsx b/src/components/text-editor/plugins/draggable-node.tsx new file mode 100644 index 0000000..d47ad3b --- /dev/null +++ b/src/components/text-editor/plugins/draggable-node.tsx @@ -0,0 +1,34 @@ +import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; +import { useDraggable } from "@dnd-kit/core"; + +const DraggableBlock = ({ node }: any) => { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: node.attrs.id, + }); + + return ( + + {/* Drag Handle */} +
+ ⠿ +
+ + {/* Content */} + +
+ ); +}; + +export default DraggableBlock; diff --git a/src/components/article/editor/styles.css b/src/components/text-editor/styles.css similarity index 83% rename from src/components/article/editor/styles.css rename to src/components/text-editor/styles.css index 00fafbc..a6d7d91 100644 --- a/src/components/article/editor/styles.css +++ b/src/components/text-editor/styles.css @@ -112,3 +112,24 @@ ol { .menu-bar .is-active { @apply border-primary; } + +.drag-handle { + @apply bg-red-500; +} + +.block-drag-handle { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #aaa; + background: white; + border-radius: 4px; + z-index: 10; +} + +.block-drag-handle:hover { + color: #333; + background: #f1f1f1; +} diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..9fa4894 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/app.config.ts b/src/config/app.config.ts similarity index 100% rename from src/app.config.ts rename to src/config/app.config.ts diff --git a/src/config/app.routes.ts b/src/config/app.routes.ts new file mode 100644 index 0000000..f37215b --- /dev/null +++ b/src/config/app.routes.ts @@ -0,0 +1,43 @@ +export type RouteWithParam = (param: string) => string; +export type Route = string | RouteWithParam; + +export type AppRoutes = { + // Home and admin + home: string; + admin: { + base: string; + }; + + // Article routes + allArticles: string; + article: RouteWithParam; + editArticle: RouteWithParam; + + // Category routes + allCategories: string; + category: RouteWithParam; + editCategory: RouteWithParam; + + // Auth routes + signin: string; + signout: string; +}; + +export const appRoutes: AppRoutes = { + home: "/", + admin: { base: "/admin" }, + + // article + allArticles: "/artikel", + article: (slug) => `/artikel/${slug}`, + editArticle: (slug) => `/artikel/${slug}/bearbeiten`, + + // category + allCategories: "/kategorie", + category: (slug) => `/kategorie/${slug}`, + editCategory: (slug) => `/kategorie/${slug}/bearbeiten`, + + // auth + signin: "/api/auth/signin", + signout: "/api/auth/signout", +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..97fc0a7 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export { appConfig } from "./app.config"; +export { appRoutes } from "./app.routes"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5435c1a..3531240 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,7 +6,13 @@ export function cn(...inputs: ClassValue[]) { } export function generateSlug(title: string) { - return title.toLowerCase().replace(/\s+/g, "-"); + return title + .toLowerCase() // Convert to lowercase + .trim() // Remove leading/trailing whitespace + .replace(/[^\w\s-]/g, "") // Remove special characters + .replace(/\s+/g, "-") // Replace spaces with hyphens + .replace(/-+/g, "-") // Remove consecutive hyphens + .replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens } export function debounce void>( @@ -20,18 +26,3 @@ export function debounce void>( timeoutId = setTimeout(() => func(...args), delay); }; } - -export function RGBAToHexA(rgba: string, forceRemoveAlpha = false) { - return ( - "#" + - rgba - .replace(/^rgba?\(|\s+|\)$/g, "") // Get's rgba / rgb string values - .split(",") // splits them at "," - .filter((string, index) => !forceRemoveAlpha || index !== 3) - .map((string) => parseFloat(string)) // Converts them to numbers - .map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number - .map((number) => number.toString(16)) // Converts numbers to hex - .map((string) => (string.length === 1 ? "0" + string : string)) // Adds 0 when length of one number is 1 - .join("") - ); // Puts the array to togehter to a string -} diff --git a/src/lib/validation/zod/article.ts b/src/lib/validation/zod/article.ts index aa2c8b2..9eed58f 100644 --- a/src/lib/validation/zod/article.ts +++ b/src/lib/validation/zod/article.ts @@ -2,8 +2,13 @@ import { z } from "zod"; export const articleSchema = z.object({ title: z.string().min(1), - content: z.string().optional(), + slug: z.string().min(1), + content: z.any().optional(), authorId: z.string().optional(), categoryId: z.string().optional(), published: z.boolean(), }); + +export const createArticleSchema = articleSchema.pick({ + title: true, +}); diff --git a/src/server/actions/article.ts b/src/server/actions/article.ts index ee3fb9d..b95a3ae 100644 --- a/src/server/actions/article.ts +++ b/src/server/actions/article.ts @@ -1,22 +1,29 @@ "use server"; -import { articleSchema } from "@/lib/validation/zod/article"; +import { appRoutes } from "@/config"; +import { + articleSchema, + createArticleSchema, +} from "@/lib/validation/zod/article"; import { api } from "@/trpc/server"; -import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -export async function createArticle(article: z.infer) { +export async function createArticle( + article: z.infer, +) { const result = await api.article.create({ article, }); if (!result[0]?.slug?.length) return false; - return redirect(`/artikel/${result[0].slug}/edit`); + return redirect(appRoutes.editArticle(result[0].slug)); } export async function updateArticle( article: z.infer, articleId: string, ) { + console.log("Content :: action", JSON.stringify(article.content)); + const result = await api.article.update({ article, articleId, diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 5d93f26..d7a44b5 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -3,7 +3,7 @@ import { categoryRouter } from "@/server/api/routers/category"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; import { usersRouter } from "./routers/users"; import { authorRouter } from "./routers/author"; - +import { appRouter as globalRouter } from "./routers/app"; /** * This is the primary router for your server. * @@ -14,6 +14,7 @@ export const appRouter = createTRPCRouter({ category: categoryRouter, users: usersRouter, author: authorRouter, + app: globalRouter, }); // export type definition of API diff --git a/src/server/api/routers/app.ts b/src/server/api/routers/app.ts new file mode 100644 index 0000000..a026bff --- /dev/null +++ b/src/server/api/routers/app.ts @@ -0,0 +1,16 @@ +import { createTRPCRouter, publicProcedure } from "../trpc"; + +export const appRouter = createTRPCRouter({ + getSidebarContent: publicProcedure.query(async ({ ctx }) => { + return await ctx.db.query.categories.findMany({ + with: { + articles: { + columns: { + title: true, + slug: true, + }, + }, + }, + }); + }), +}); diff --git a/src/server/api/routers/article.ts b/src/server/api/routers/article.ts index 90fe7a1..dd13728 100644 --- a/src/server/api/routers/article.ts +++ b/src/server/api/routers/article.ts @@ -6,7 +6,10 @@ import { publicProcedure, } from "@/server/api/trpc"; import { articles } from "@/server/db/schema"; -import { articleSchema } from "@/lib/validation/zod/article"; +import { + articleSchema, + createArticleSchema, +} from "@/lib/validation/zod/article"; import { count, eq } from "drizzle-orm"; import { hasPermission, Role } from "@/lib/validation/permissions"; import { generateSlug } from "@/lib/utils"; @@ -18,6 +21,7 @@ export const articleRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { return await ctx.db.query.articles.findFirst({ where: eq(articles.slug, input.slug), + with: { category: true }, }); }), @@ -60,7 +64,7 @@ export const articleRouter = createTRPCRouter({ }), // mutations create: protectedProcedure - .input(z.object({ article: articleSchema })) + .input(z.object({ article: createArticleSchema })) .mutation(async ({ ctx, input }) => { const isEditor = hasPermission(ctx.session.user.role, Role.EDITOR); if (!isEditor) { @@ -81,6 +85,11 @@ export const articleRouter = createTRPCRouter({ if (!isEditor) { throw new Error("You are not allowed to update articles"); } + console.log( + "Content before save", + JSON.stringify(input.article.content), + ); + return await ctx.db .update(articles) .set(input.article) diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 4eae3ee..9c20ca9 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -4,6 +4,7 @@ import { boolean, index, integer, + jsonb, pgTableCreator, primaryKey, text, @@ -11,6 +12,8 @@ import { varchar, } from "drizzle-orm/pg-core"; import { type AdapterAccount } from "next-auth/adapters"; +import { type JSONContent } from "@tiptap/react"; + /** * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same * database instance for multiple projects. @@ -27,9 +30,9 @@ export const articles = createTable( .$defaultFn(() => createId()) .notNull(), title: varchar("title", { length: 256 }), - slug: varchar("slug", { length: 256 }).unique(), + slug: varchar("slug", { length: 256 }).unique().notNull(), authorId: varchar("author_id", { length: 255 }), - content: text("content"), + content: text("content"), categoryId: varchar("category_id", { length: 255 }), published: boolean("published").default(false), createdAt: timestamp("created_at", { withTimezone: true }) @@ -44,9 +47,8 @@ export const articles = createTable( }), ); export type Article = typeof articles.$inferSelect; - export const articleRelations = relations(articles, ({ one }) => ({ - categories: one(categories, { + category: one(categories, { fields: [articles.categoryId], references: [categories.id], }), @@ -60,7 +62,7 @@ export const categories = createTable( .$defaultFn(() => createId()) .notNull(), name: varchar("name", { length: 256 }), - slug: varchar("slug", { length: 256 }).unique(), + slug: varchar("slug", { length: 256 }).unique().notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .default(sql`CURRENT_TIMESTAMP`)