From 0f7bf096762846e4243880085719bf74f9ff647e Mon Sep 17 00:00:00 2001 From: mr-shortman Date: Sat, 15 Mar 2025 11:53:18 +0100 Subject: [PATCH] refined infinite article grid and added article filtering --- package.json | 2 +- pnpm-lock.yaml | 148 +++++++++---- seed-data/seed.ts | 36 +++- src/app/(PAGES)/_components/main-page.tsx | 2 +- src/app/(PAGES)/layout.tsx | 10 +- src/app/layout.tsx | 18 +- src/components/article/article-filter-bar.tsx | 120 +++++++++++ .../article/create-article-dialog.tsx | 3 +- .../article/grid/infinite-article-grid.tsx | 13 +- src/components/avatar.tsx | 2 +- src/components/category/category-select.tsx | 39 ++-- .../category/create-category-dialog.tsx | 3 +- src/components/combobox.tsx | 59 +++-- src/components/editor-dropdown.tsx | 36 ++++ src/components/global-search-widget.tsx | 43 ---- src/components/icons.tsx | 29 ++- src/components/layout/app-sidebar/index.tsx | 105 +++++++++ .../layout/app-sidebar/nav-branding.tsx | 31 +++ .../layout/app-sidebar/nav-main.tsx | 79 +++++++ .../layout/app-sidebar/nav-secondary.tsx | 41 ++++ .../layout/app-sidebar/nav-team-section.tsx | 34 +++ .../layout/app-sidebar/nav-user.tsx | 114 ++++++++++ src/components/layout/navbar.tsx | 59 +---- src/components/layout/wiki-sidebar.tsx | 97 --------- src/components/mode-switch.tsx | 40 ++++ src/components/theme-provider.tsx | 11 + src/components/ui/dropdown-menu.tsx | 201 ++++++++++++++++++ src/config/app.config.ts | 7 + src/lib/validation/zod/article.ts | 6 + src/server/api/routers/article.ts | 42 +++- src/styles/globals.css | 59 ++--- 31 files changed, 1156 insertions(+), 333 deletions(-) create mode 100644 src/components/article/article-filter-bar.tsx create mode 100644 src/components/editor-dropdown.tsx delete mode 100644 src/components/global-search-widget.tsx create mode 100644 src/components/layout/app-sidebar/index.tsx create mode 100644 src/components/layout/app-sidebar/nav-branding.tsx create mode 100644 src/components/layout/app-sidebar/nav-main.tsx create mode 100644 src/components/layout/app-sidebar/nav-secondary.tsx create mode 100644 src/components/layout/app-sidebar/nav-team-section.tsx create mode 100644 src/components/layout/app-sidebar/nav-user.tsx delete mode 100644 src/components/layout/wiki-sidebar.tsx create mode 100644 src/components/mode-switch.tsx create mode 100644 src/components/theme-provider.tsx create mode 100644 src/components/ui/dropdown-menu.tsx diff --git a/package.json b/package.json index 5358375..7e16507 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", @@ -45,7 +46,6 @@ "@trpc/client": "^11.0.0-rc.446", "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", - "babel-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301", "cheerio": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f25fd..e667b4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@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) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.6 + version: 2.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) '@radix-ui/react-label': specifier: ^2.1.2 version: 2.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) @@ -83,9 +86,6 @@ importers: '@trpc/server': specifier: ^11.0.0-rc.446 version: 11.0.0-rc.824(typescript@5.8.2) - babel-plugin-react-compiler: - specifier: 19.0.0-beta-40c6c23-20250301 - version: 19.0.0-beta-40c6c23-20250301 cheerio: specifier: ^1.0.0 version: 1.0.0 @@ -103,16 +103,16 @@ importers: version: 0.33.0(@types/react@18.3.18)(postgres@3.4.5)(react@18.3.1) geist: specifier: ^1.3.0 - version: 1.3.1(next@15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.3.1(next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) lucide-react: specifier: ^0.477.0 version: 0.477.0(react@18.3.1) next: specifier: ^15.0.1 - version: 15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: 5.0.0-beta.25 - version: 5.0.0-beta.25(next@15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 5.0.0-beta.25(next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.4 version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -248,22 +248,10 @@ packages: '@auth/drizzle-adapter@1.8.0': resolution: {integrity: sha512-cxApE0h5WcyDsgGix9hzmWmCz0qxvmMJexAOQmI6R/YXYxrZ/mKBKu0BlfgQBR6z2XvNWl4wbEGchwSenSCksQ==} - '@babel/helper-string-parser@7.25.9': - resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.25.9': - resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.26.9': resolution: {integrity: sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==} engines: {node: '>=6.9.0'} - '@babel/types@7.26.9': - resolution: {integrity: sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==} - engines: {node: '>=6.9.0'} - '@cfcs/core@0.0.6': resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==} @@ -1185,6 +1173,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.6': + resolution: {integrity: sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==} + 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-focus-guards@1.0.1': resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} peerDependencies: @@ -1260,6 +1261,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.6': + resolution: {integrity: sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==} + 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-popover@1.1.6': resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} peerDependencies: @@ -1364,6 +1378,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.2': + resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} + 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-select@2.1.6': resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} peerDependencies: @@ -2070,9 +2097,6 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301: - resolution: {integrity: sha512-himtjPafvMbA7PYnV2L+jprpB3h4rhx/n5s4L3gC654FOUsmsv5n4p8d6ufvK2zqUQs4kTOjgT2b4wnuDU32CA==} - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4295,19 +4319,10 @@ snapshots: - '@simplewebauthn/server' - nodemailer - '@babel/helper-string-parser@7.25.9': {} - - '@babel/helper-validator-identifier@7.25.9': {} - '@babel/runtime@7.26.9': dependencies: regenerator-runtime: 0.14.1 - '@babel/types@7.26.9': - dependencies: - '@babel/helper-string-parser': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@cfcs/core@0.0.6': dependencies: '@egjs/component': 3.0.5 @@ -4975,6 +4990,21 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dropdown-menu@2.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)': + 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-menu': 2.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) + '@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) + 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-focus-guards@1.0.1(@types/react@18.3.18)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.9 @@ -5035,6 +5065,32 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-menu@2.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)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@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) + '@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-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.5(@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-focus-guards': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 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-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.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-portal': 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-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-roving-focus': 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-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.3(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popover@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)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -5136,6 +5192,23 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@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/primitive': 1.1.1 + '@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) + '@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-direction': 1.1.0(@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-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-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 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-select@2.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)': dependencies: '@radix-ui/number': 1.1.0 @@ -5871,10 +5944,6 @@ snapshots: axobject-query@4.1.0: {} - babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301: - dependencies: - '@babel/types': 7.26.9 - bail@2.0.2: {} balanced-match@1.0.2: {} @@ -6672,9 +6741,9 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.3.1(next@15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + geist@1.3.1(next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: - next: 15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) gesto@1.19.4: dependencies: @@ -7355,10 +7424,10 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.25(next@15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + next-auth@5.0.0-beta.25(next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@auth/core': 0.37.2 - next: 15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 next-themes@0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -7366,7 +7435,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@15.2.1(babel-plugin-react-compiler@19.0.0-beta-40c6c23-20250301)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.2.1 '@swc/counter': 0.1.3 @@ -7386,7 +7455,6 @@ snapshots: '@next/swc-linux-x64-musl': 15.2.1 '@next/swc-win32-arm64-msvc': 15.2.1 '@next/swc-win32-x64-msvc': 15.2.1 - babel-plugin-react-compiler: 19.0.0-beta-40c6c23-20250301 sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' diff --git a/seed-data/seed.ts b/seed-data/seed.ts index b98183b..1135ace 100644 --- a/seed-data/seed.ts +++ b/seed-data/seed.ts @@ -2,21 +2,39 @@ import "dotenv/config"; import { db, DBType } from "../src/server/db"; import { articles, categories, users } from "../src/server/db/schema"; import fakeArticles from "./fake-articles.json"; +import fakeUsers from "./fake-users.json"; +import fakeCategories from "./fake-categories.json"; import { generateSlug } from "@/lib/utils"; import { createId } from "@paralleldrive/cuid2"; -import { eq, sql } from "drizzle-orm"; async function seed() { - // await db.insert(users).values(fakeUsers); - // await db - // .insert(categories) - // .values( - // fakeCategories.map(({ name }) => ({ name, slug: generateSlug(name) })), - // ); + const u = await db + .insert(users) + .values(fakeUsers) + .returning({ id: users.id }); + console.log("Seeded " + u.length + " users"); - await db + const c = await db + .insert(categories) + .values( + fakeCategories.map(({ name }) => ({ name, slug: generateSlug(name) })), + ) + .returning({ + id: categories.id, + }); + console.log("Seeded " + c.length + " categories"); + + const a = await db .insert(articles) - .values(fakeArticles.map(({ title }) => ({ title, slug: createId() }))); + .values( + fakeArticles.map(({ title }) => ({ + title, + slug: createId(), + published: true, + })), + ) + .returning({ id: articles.id }); + console.log("Seeded " + a.length + " articles"); } seed() diff --git a/src/app/(PAGES)/_components/main-page.tsx b/src/app/(PAGES)/_components/main-page.tsx index a24019e..08dee67 100644 --- a/src/app/(PAGES)/_components/main-page.tsx +++ b/src/app/(PAGES)/_components/main-page.tsx @@ -9,7 +9,7 @@ import ArticleGrid, { import { Article, Category } from "@/server/db/schema"; import ArrowLink from "@/components/arrow-link"; import { appRoutes } from "@/config"; -import GlobalSearchWidget from "@/components/global-search-widget"; +import GlobalSearchWidget from "@/components/article/article-filter-bar"; import { api } from "@/trpc/react"; import { useInfiniteQuery } from "@tanstack/react-query"; diff --git a/src/app/(PAGES)/layout.tsx b/src/app/(PAGES)/layout.tsx index 1e1faab..6d49978 100644 --- a/src/app/(PAGES)/layout.tsx +++ b/src/app/(PAGES)/layout.tsx @@ -1,13 +1,15 @@ -import { WikiSidebar } from "@/components/layout/wiki-sidebar"; +import { AppSidebar } from "@/components/layout/app-sidebar"; import Navbar from "@/components/layout/navbar"; import { SidebarProvider } from "@/components/ui/sidebar"; +import { auth } from "@/server/auth"; import React from "react"; -function Layout({ children }: { children: React.ReactNode }) { +async function Layout({ children }: { children: React.ReactNode }) { + const session = await auth(); return ( - -
+ +
{children}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 55c5ce2..0ec361c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { type Metadata } from "next"; import { TRPCReactProvider } from "@/trpc/react"; import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/components/theme-provider"; export const metadata: Metadata = { title: "Create T3 App", @@ -16,11 +17,22 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + - - {children} + + + {children} + diff --git a/src/components/article/article-filter-bar.tsx b/src/components/article/article-filter-bar.tsx new file mode 100644 index 0000000..6731931 --- /dev/null +++ b/src/components/article/article-filter-bar.tsx @@ -0,0 +1,120 @@ +"use client"; +import { cn, debounce } from "@/lib/utils"; +import React from "react"; +import { Input } from "../ui/input"; +import { + ArrowDownAZ, + ArrowUpAz, + CalendarArrowDown, + CalendarArrowUp, + Eye, + FilterIcon, + LucideIcon, + MessageSquare, + SearchIcon, +} from "lucide-react"; +import CategorySelect from "../category/category-select"; +import { Combobox } from "../combobox"; + +export type ArticleFilter = { + query?: string; + category?: string; + sort?: string; +}; + +function ArticleFilterBar({ + className, + onFilterUpdate, +}: { + className?: string; + onFilterUpdate: (filter: ArticleFilter) => void; +}) { + const [filter, setFilter] = React.useState({ + query: "", + }); + + const onFilterChange = React.useCallback( + (newFilter: Partial, notify: boolean = true) => { + const f = { ...filter, ...newFilter }; + setFilter(f); + if (notify) onFilterUpdate(f); + }, + [filter, onFilterUpdate], // Ensure dependencies are listed + ); + + const debouncedSearch = React.useMemo( + () => + debounce((query: string) => { + onFilterChange({ query }); + }, 300), + [], + ); + + return ( + <> +
+
+ + { + onFilterChange({ query: e.currentTarget.value }, false); + debouncedSearch(e.currentTarget.value); + }} + placeholder="Artikel Suche..." + /> +
+ { + onFilterChange({ + category: category?.length ? category : undefined, + }); + }} + buttonProps={{ + size: "sm", + }} + /> + { + onFilterChange({ + sort: currentValue?.length ? currentValue : undefined, + }); + }} + className="w-full max-w-64" + messageUi={{ + selectIcon: FilterIcon, + select: "Sortieren", + }} + buttonProps={{ + size: "sm", + }} + /> +
+ + ); +} + +export default ArticleFilterBar; + +const sortItems: Array<{ + Icon: LucideIcon; + value: string; + label: string; +}> = [ + { Icon: CalendarArrowUp, value: "newest", label: "Neueste" }, + { Icon: CalendarArrowDown, value: "oldest", label: "Älteste" }, + { Icon: ArrowDownAZ, value: "abc", label: "Alphabetisch A-Z" }, + { Icon: ArrowUpAz, value: "cba", label: "Alphabetisch Z-A" }, + // { Icon: Eye, value: "popular", label: "Beliebteste" }, + // { Icon: MessageSquare, value: "commented", label: "Meistkommentiert" }, +]; diff --git a/src/components/article/create-article-dialog.tsx b/src/components/article/create-article-dialog.tsx index a00079d..cd086b9 100644 --- a/src/components/article/create-article-dialog.tsx +++ b/src/components/article/create-article-dialog.tsx @@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input"; import { createArticleSchema } from "@/lib/validation/zod/article"; import { createArticle } from "@/server/actions/article"; -function CreateArticleDialog() { +function CreateArticleDialog({ cb }: { cb?: () => void }) { const [open, setOpen] = React.useState(false); const form = useForm>({ resolver: zodResolver(createArticleSchema), @@ -38,6 +38,7 @@ function CreateArticleDialog() { // 2. Define a submit handler. async function onSubmit(values: z.infer) { setOpen(false); + cb?.(); await createArticle(values); } return ( diff --git a/src/components/article/grid/infinite-article-grid.tsx b/src/components/article/grid/infinite-article-grid.tsx index b8dbece..30a9ad2 100644 --- a/src/components/article/grid/infinite-article-grid.tsx +++ b/src/components/article/grid/infinite-article-grid.tsx @@ -6,11 +6,17 @@ import { ARTICLE_GRID_CLASS } from "./article-grid"; import ArticleCard from "../article-card"; import { useInfiniteItemsObserver } from "@/lib/hooks/infinite-items-observer-hook"; import { Skeleton } from "@/components/ui/skeleton"; +import ArticleFilterBar, { ArticleFilter } from "../article-filter-bar"; function InfiniteArticlesGrid() { + const [filter, setFilter] = React.useState( + undefined, + ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - api.article.getByPage.useInfiniteQuery( - {}, + api.article.getByCursor.useInfiniteQuery( + { + filter, + }, { getNextPageParam: (lastPage) => lastPage.nextCursor, }, @@ -31,7 +37,8 @@ function InfiniteArticlesGrid() { }); return ( -
+
+ {data?.pages?.length ? allItems.map((article, idx) => ( diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index 054d4c1..a8fb86a 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -16,7 +16,7 @@ function Avatar({ }) { return ( - + {fb?.slice(0, 2)?.toUpperCase()} ); diff --git a/src/components/category/category-select.tsx b/src/components/category/category-select.tsx index 486e226..826b700 100644 --- a/src/components/category/category-select.tsx +++ b/src/components/category/category-select.tsx @@ -1,33 +1,24 @@ "use client"; import { api } from "@/trpc/react"; import React from "react"; -import { Combobox } from "../combobox"; +import { Combobox, ComboboxProps } from "../combobox"; +import { Icons } from "../icons"; -function CategorySelect({ - initialValue, - onSelect, -}: { - initialValue?: string; - onSelect: (category?: string) => void; -}) { +function CategorySelect(props: Partial) { const { data: categories } = api.category.getAll.useQuery(); return ( -
- ({ label: name!, value: id! })) ?? - [] - } - initialValue={initialValue} - onSelect={onSelect} - className="w-full" - messageUi={{ - select: "Kategorie auswählen...", - placeholder: "Kategorie suchen...", - empty: "Keine Kategorien gefunden", - }} - /> -
+ ({ label: name!, value: id! })) ?? [] + } + /> ); } diff --git a/src/components/category/create-category-dialog.tsx b/src/components/category/create-category-dialog.tsx index 7af0eb9..35820c2 100644 --- a/src/components/category/create-category-dialog.tsx +++ b/src/components/category/create-category-dialog.tsx @@ -30,7 +30,7 @@ const formSchema = categorySchema.pick({ name: true, }); -function CreateCategoryDialog() { +function CreateCategoryDialog({ cb }: { cb?: () => void }) { const [open, setOpen] = React.useState(false); const form = useForm>({ resolver: zodResolver(formSchema), @@ -42,6 +42,7 @@ function CreateCategoryDialog() { // 2. Define a submit handler. async function onSubmit(values: z.infer) { setOpen(false); + cb?.(); await createCategory(values); } return ( diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx index 580e3a5..1cedcf0 100644 --- a/src/components/combobox.tsx +++ b/src/components/combobox.tsx @@ -1,10 +1,10 @@ "use client"; import * as React from "react"; -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; +import { Button, ButtonProps } from "@/components/ui/button"; import { Command, CommandEmpty, @@ -19,19 +19,23 @@ import { PopoverTrigger, } from "@/components/ui/popover"; -type ComboboxProps = { +export type ComboboxProps = { data: { label: string; value: string; + Icon?: LucideIcon; }[]; - onSelect: (value: string) => void; + onSelect: (value?: string) => void; messageUi?: { select?: string; + selectIcon?: LucideIcon; empty?: string; placeholder?: string; }; initialValue?: string; className?: string; + hideSearch?: boolean; + buttonProps?: ButtonProps; }; export function Combobox({ @@ -39,11 +43,13 @@ export function Combobox({ initialValue, messageUi, className, + hideSearch, + buttonProps, onSelect, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(initialValue ?? ""); - + const selectedItem = data.find((item) => item.value === value)!; return ( @@ -55,10 +61,27 @@ export function Combobox({ "w-[200px] justify-between shadow-none hover:bg-background", className, )} + {...buttonProps} > - {value - ? data.find((item) => item.value === value)?.label - : (messageUi?.select ?? "Select...")} +
+ {selectedItem?.Icon ? ( + + ) : messageUi?.selectIcon ? ( + + ) : null} + + + {selectedItem?.label + ? selectedItem.label + : (messageUi?.select ?? "Select...")} + +
+
@@ -75,10 +98,12 @@ export function Combobox({ : 0; }} > - + {!hideSearch && ( + + )} {messageUi?.empty ?? "Nothing found."} @@ -87,16 +112,18 @@ export function Combobox({ key={item.value} value={item.value} onSelect={(currentValue) => { - setValue(currentValue === value ? "" : currentValue); - onSelect(currentValue); + const newValue = currentValue === value ? "" : currentValue; + setValue(newValue); + onSelect(newValue); setOpen(false); }} >
+ {item?.Icon && } {item.label} - + {/* {value === item.value && "ausgewählt"} - + */}
+ + + + + + setOpen(false)} /> + + setOpen(false)} /> + +
+ ); +} + +export default EditorDropdown; diff --git a/src/components/global-search-widget.tsx b/src/components/global-search-widget.tsx deleted file mode 100644 index 41401f7..0000000 --- a/src/components/global-search-widget.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; -import { cn, debounce } from "@/lib/utils"; -import React from "react"; -import { Input } from "./ui/input"; - -function GlobalSearchWidget({ - className, - onDebouncedSearch, -}: { - className?: string; - onDebouncedSearch: (query: string) => void; -}) { - const [query, setQuery] = React.useState(""); - - const debouncedSearch = React.useMemo( - () => - debounce((q: string) => { - onDebouncedSearch(q); - }, 300), - [], - ); - - return ( -
- { - setQuery(e.currentTarget.value); - debouncedSearch(e.currentTarget.value); - }} - placeholder="Suche Artikel oder Kategorien..." - /> -
- ); -} - -export default GlobalSearchWidget; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index c1ee98f..6e73a37 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1,4 +1,4 @@ -import { LucideProps } from "lucide-react"; +import { Folder, LucideIcon, LucideProps, Newspaper } from "lucide-react"; export const Icons = { logo(props: LucideProps) { @@ -22,4 +22,31 @@ export const Icons = { ); }, + + category: Folder, + article: Newspaper, + + // socials + discord(props: LucideProps) { + return ( + + + + + + ); + }, }; diff --git a/src/components/layout/app-sidebar/index.tsx b/src/components/layout/app-sidebar/index.tsx new file mode 100644 index 0000000..5dba202 --- /dev/null +++ b/src/components/layout/app-sidebar/index.tsx @@ -0,0 +1,105 @@ +"use client"; +import * as React from "react"; +import { Folder, LifeBuoy, Newspaper, Send } from "lucide-react"; + +import { NavMain } from "./nav-main"; + +import { NavSecondary } from "./nav-secondary"; +import { NavUser } from "./nav-user"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, +} from "@/components/ui/sidebar"; + +import { User } from "next-auth"; +import NavTeamSection from "./nav-team-section"; +import NavBranding from "./nav-branding"; +import { Icons } from "@/components/icons"; +import { appConfig } from "@/config"; + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Artikel", + url: "/artikel", + isActive: true, + icon: Newspaper, + items: [ + { + title: "Genesis", + url: "#", + }, + { + title: "Explorer", + url: "#", + }, + { + title: "Quantum", + url: "#", + }, + { + title: "Alle Artikel", + url: "/artikel", + }, + ], + }, + { + title: "Kategorien", + url: "/kategorien", + icon: Folder, + items: [ + { + title: "History", + url: "#", + }, + { + title: "Starred", + url: "#", + }, + { + title: "Settings", + url: "#", + }, + { + title: "Alle Kategorien", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Discord", + url: appConfig.socials.discord, + icon: Icons.discord, + external: true, + }, + ], +}; + +export function AppSidebar({ + ...props +}: React.ComponentProps & { user?: User | null }) { + return ( + + + + + + + + + + {props?.user && } + {" "} + + + ); +} diff --git a/src/components/layout/app-sidebar/nav-branding.tsx b/src/components/layout/app-sidebar/nav-branding.tsx new file mode 100644 index 0000000..9548880 --- /dev/null +++ b/src/components/layout/app-sidebar/nav-branding.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Icons } from "@/components/icons"; +import { appConfig } from "@/config"; +import Link from "next/link"; + +function NavBranding({ subTitle }: { subTitle: string }) { + return ( + + + + +
+ +
+
+ {appConfig.name} + {subTitle} +
+ +
+
+
+ ); +} + +export default NavBranding; diff --git a/src/components/layout/app-sidebar/nav-main.tsx b/src/components/layout/app-sidebar/nav-main.tsx new file mode 100644 index 0000000..07a5d9e --- /dev/null +++ b/src/components/layout/app-sidebar/nav-main.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ChevronRight, type LucideIcon } from "lucide-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + items?: { + title: string; + url: string; + hideBorder?: boolean; + }[]; + }[]; +}) { + return ( + + Platform + + {items.map((item) => ( + + + + + + {item.title} + + + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ) : null} + + + ))} + + + ); +} diff --git a/src/components/layout/app-sidebar/nav-secondary.tsx b/src/components/layout/app-sidebar/nav-secondary.tsx new file mode 100644 index 0000000..938164d --- /dev/null +++ b/src/components/layout/app-sidebar/nav-secondary.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { LucideProps, type LucideIcon } from "lucide-react"; + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string; + url: string; + icon: (props: LucideProps) => React.ReactNode; + external?: boolean; + }[]; +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ); +} diff --git a/src/components/layout/app-sidebar/nav-team-section.tsx b/src/components/layout/app-sidebar/nav-team-section.tsx new file mode 100644 index 0000000..dda614b --- /dev/null +++ b/src/components/layout/app-sidebar/nav-team-section.tsx @@ -0,0 +1,34 @@ +import { Button } from "@/components/ui/button"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { hasPermission, Role } from "@/lib/validation/permissions"; +import Link from "next/link"; +import React from "react"; + +function NavTeamSection({ userRole }: { userRole: UserRole }) { + const isAdmin = hasPermission(userRole, Role.ADMIN); + + return ( + <> + {isAdmin && ( + + + + + + + + )} + + ); +} + +export default NavTeamSection; diff --git a/src/components/layout/app-sidebar/nav-user.tsx b/src/components/layout/app-sidebar/nav-user.tsx new file mode 100644 index 0000000..6dce667 --- /dev/null +++ b/src/components/layout/app-sidebar/nav-user.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + BadgeCheck, + Bell, + ChevronsUpDown, + CreditCard, + LogOut, + Sparkles, +} from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { appRoutes } from "@/config"; +import Avatar from "@/components/avatar"; + +export function NavUser({ user }: { user?: any }) { + const { isMobile } = useSidebar(); + if (!user) + return ( + + ); + return ( + + + + + + + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ +
+ {user.name} + {user.email} +
+
+
+ + + + + Upgrade to Pro + + + + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ); +} diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index fecb520..8752c65 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -1,65 +1,22 @@ import React from "react"; -import { Input } from "../ui/input"; import { auth } from "@/server/auth"; -import { Button } from "../ui/button"; -import Link from "next/link"; -import Avatar from "../avatar"; + import { hasPermission, Role } from "@/lib/validation/permissions"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import CreateArticleDialog from "@/components/article/create-article-dialog"; -import CreateCategoryDialog from "@/components/category/create-category-dialog"; -import { appRoutes } from "@/config"; + +import { ModeToggle } from "../mode-switch"; +import EditorDropdown from "../editor-dropdown"; async function Navbar() { const session = await auth(); const isEditor = session?.user ? hasPermission(session.user.role, Role.EDITOR) : false; - const isAdmin = session?.user - ? hasPermission(session.user.role, Role.ADMIN) - : false; + return ( -
- +
+ {isEditor && } -
- {isEditor && ( - - - - - - - - - - - )} - {isAdmin && ( - - )} - {session ? ( - - ) : ( - - )} -
+
); } diff --git a/src/components/layout/wiki-sidebar.tsx b/src/components/layout/wiki-sidebar.tsx deleted file mode 100644 index 44233e0..0000000 --- a/src/components/layout/wiki-sidebar.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; - -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubItem, -} from "@/components/ui/sidebar"; -import Link from "next/link"; -import { Separator } from "../ui/separator"; -import { Minus, Plus } from "lucide-react"; -import { api } from "@/trpc/server"; -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 WikiSidebar({ - ...props -}: React.ComponentProps) { - const sidebarContent = await api.app.getSidebarContent(); - return ( - - - - {appConfig.name} - - - - - - {sidebarContent?.map((category, index) => ( - - - - - - {category.name} - - - - - - {category?.articles?.length ? ( - - - {category.articles.map((article) => ( - - - - ))} - - - ) : null} - - - ))} - - - - -
- - Impressum - - - - Datenschutz - -
-
-
- ); -} diff --git a/src/components/mode-switch.tsx b/src/components/mode-switch.tsx new file mode 100644 index 0000000..586a6e5 --- /dev/null +++ b/src/components/mode-switch.tsx @@ -0,0 +1,40 @@ +"use client"; + +import * as React from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Hell + + setTheme("dark")}> + Dunkel + + setTheme("system")}> + System + + + + ); +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..189a2b1 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..1154fb9 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/config/app.config.ts b/src/config/app.config.ts index f84ee0d..4e7baa2 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,7 +1,14 @@ export type AppConfig = { name: string; + socials: { + discord: string; + }; }; export const appConfig: AppConfig = { name: "Logipedia", + + socials: { + discord: "https://discord.com", + }, }; diff --git a/src/lib/validation/zod/article.ts b/src/lib/validation/zod/article.ts index 9eed58f..01fccf3 100644 --- a/src/lib/validation/zod/article.ts +++ b/src/lib/validation/zod/article.ts @@ -12,3 +12,9 @@ export const articleSchema = z.object({ export const createArticleSchema = articleSchema.pick({ title: true, }); + +export const articleFilterSchema = z.object({ + query: z.string().optional(), + category: z.string().optional(), + sort: z.string().optional(), +}); diff --git a/src/server/api/routers/article.ts b/src/server/api/routers/article.ts index 1cf0c20..c92cec4 100644 --- a/src/server/api/routers/article.ts +++ b/src/server/api/routers/article.ts @@ -7,13 +7,29 @@ import { } from "@/server/api/trpc"; import { Article, articles } from "@/server/db/schema"; import { + articleFilterSchema, articleSchema, createArticleSchema, } from "@/lib/validation/zod/article"; -import { and, count, eq, gt, like, sql } from "drizzle-orm"; +import { and, asc, count, desc, eq, gt, ilike, like, sql } from "drizzle-orm"; import { hasPermission, Role } from "@/lib/validation/permissions"; import { generateSlug } from "@/lib/utils"; +const getArticleSorting = (sort: string) => { + switch (sort) { + case "newest": + return desc(articles.createdAt); + case "oldest": + return asc(articles.createdAt); + case "abc": + return asc(articles.title); + case "cba": + return desc(articles.title); + default: + return desc(articles.createdAt); // Default to newest + } +}; + export const articleRouter = createTRPCRouter({ // queries search: publicProcedure @@ -32,25 +48,35 @@ export const articleRouter = createTRPCRouter({ with: { category: true }, }); }), - getByPage: publicProcedure + getByCursor: publicProcedure .input( z.object({ - categoryId: z.string().optional(), limit: z.number().optional(), cursor: z.string().optional(), + filter: articleFilterSchema.optional(), }), ) .query(async ({ ctx, input }) => { - const { categoryId, cursor } = input!; + const { cursor } = input!; const limit = input?.limit ?? 50; const cursorArg = cursor ? gt(articles.slug, cursor) : undefined; - const categoryArg = categoryId - ? eq(articles.categoryId, categoryId) + + const queryFilterArg = input?.filter?.query?.length + ? ilike(articles.title, "%" + input.filter.query + "%") : undefined; + const categoryArg = input?.filter?.category + ? eq(articles.categoryId, input.filter.category) + : undefined; + const orderBy = getArticleSorting(input?.filter?.sort ?? "newest"); const items = await ctx.db.query.articles.findMany({ - where: and(cursorArg, categoryArg), + where: and( + cursorArg, + categoryArg, + queryFilterArg, + eq(articles.published, true), + ), limit: limit + 1, - orderBy: articles.slug, + orderBy, columns: { title: true, slug: true, diff --git a/src/styles/globals.css b/src/styles/globals.css index 9e47cfe..ef28557 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -5,32 +5,32 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 0 0% 3.9%; + --foreground: 240 10% 3.9%; --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; - --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; + /* --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; */ --sidebar-primary: 240 5.9% 10%; --sidebar-primary-foreground: 0 0% 98%; --sidebar-accent: 240 4.8% 95.9%; @@ -38,33 +38,34 @@ --sidebar-border: 220 13% 91%; --sidebar-ring: 217.2 91.2% 59.8%; } + .dark { - --background: 0 0% 3.9%; + --background: 240 10% 3.9%; --foreground: 0 0% 98%; - --card: 0 0% 3.9%; + --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; + --popover: 240 10% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; + /* --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; */ --sidebar-primary: 224.3 76.3% 48%; --sidebar-primary-foreground: 0 0% 100%; --sidebar-accent: 240 3.7% 15.9%;