refined infinite article grid and added article filtering

This commit is contained in:
mr-shortman 2025-03-15 11:53:18 +01:00
parent 37233db0ec
commit 0f7bf09676
31 changed files with 1156 additions and 333 deletions

View File

@ -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",

148
pnpm-lock.yaml generated
View File

@ -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'

View File

@ -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()

View File

@ -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";

View File

@ -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 (
<SidebarProvider>
<WikiSidebar />
<div className="w-full">
<AppSidebar user={session?.user} />
<div className="h-screen w-full bg-background">
<Navbar />
<main className="space-y-4 p-4">{children}</main>
</div>

View File

@ -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 (
<html lang="en" className={`${GeistSans.variable}`}>
<html
suppressHydrationWarning
lang="en"
className={`${GeistSans.variable}`}
>
<body>
<TRPCReactProvider>
<Toaster />
{children}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Toaster />
{children}
</ThemeProvider>
</TRPCReactProvider>
</body>
</html>

View File

@ -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<ArticleFilter>({
query: "",
});
const onFilterChange = React.useCallback(
(newFilter: Partial<ArticleFilter>, 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 (
<>
<div
className={cn(
"flex w-full items-center justify-center gap-2 rounded-md border bg-background p-2",
className,
)}
>
<div className="relative w-full">
<SearchIcon className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="border-0 p-0 pl-8 shadow-none focus-visible:ring-0"
value={filter.query}
onChange={(e) => {
onFilterChange({ query: e.currentTarget.value }, false);
debouncedSearch(e.currentTarget.value);
}}
placeholder="Artikel Suche..."
/>
</div>
<CategorySelect
className="w-full max-w-xs"
onSelect={(category) => {
onFilterChange({
category: category?.length ? category : undefined,
});
}}
buttonProps={{
size: "sm",
}}
/>
<Combobox
hideSearch
data={sortItems}
initialValue={filter.sort}
onSelect={(currentValue) => {
onFilterChange({
sort: currentValue?.length ? currentValue : undefined,
});
}}
className="w-full max-w-64"
messageUi={{
selectIcon: FilterIcon,
select: "Sortieren",
}}
buttonProps={{
size: "sm",
}}
/>
</div>
</>
);
}
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" },
];

View File

@ -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<boolean>(false);
const form = useForm<z.infer<typeof createArticleSchema>>({
resolver: zodResolver(createArticleSchema),
@ -38,6 +38,7 @@ function CreateArticleDialog() {
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof createArticleSchema>) {
setOpen(false);
cb?.();
await createArticle(values);
}
return (

View File

@ -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<ArticleFilter | undefined>(
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 (
<div className="relative">
<div className="relative space-y-4">
<ArticleFilterBar onFilterUpdate={setFilter} />
<menu className={`${ARTICLE_GRID_CLASS} overflow-auto`}>
{data?.pages?.length
? allItems.map((article, idx) => (

View File

@ -16,7 +16,7 @@ function Avatar({
}) {
return (
<AvatarComponent className={className}>
<AvatarImage src={src!} />
<AvatarImage src={src!} alt={fb!} />
<AvatarFallback>{fb?.slice(0, 2)?.toUpperCase()}</AvatarFallback>
</AvatarComponent>
);

View File

@ -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<ComboboxProps>) {
const { data: categories } = api.category.getAll.useQuery();
return (
<div>
<Combobox
data={
categories?.map(({ name, id }) => ({ label: name!, value: id! })) ??
[]
}
initialValue={initialValue}
onSelect={onSelect}
className="w-full"
messageUi={{
select: "Kategorie auswählen...",
placeholder: "Kategorie suchen...",
empty: "Keine Kategorien gefunden",
}}
/>
</div>
<Combobox
{...(props as ComboboxProps)}
messageUi={{
select: "Kategorie auswählen...",
selectIcon: Icons.category,
placeholder: "Kategorie suchen...",
empty: "Keine Kategorien gefunden",
}}
data={
categories?.map(({ name, id }) => ({ label: name!, value: id! })) ?? []
}
/>
);
}

View File

@ -30,7 +30,7 @@ const formSchema = categorySchema.pick({
name: true,
});
function CreateCategoryDialog() {
function CreateCategoryDialog({ cb }: { cb?: () => void }) {
const [open, setOpen] = React.useState<boolean>(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -42,6 +42,7 @@ function CreateCategoryDialog() {
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof formSchema>) {
setOpen(false);
cb?.();
await createCategory(values);
}
return (

View File

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -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...")}
<div
className={cn(
"flex items-center gap-2 text-muted-foreground",
selectedItem && "text-foreground",
)}
>
{selectedItem?.Icon ? (
<selectedItem.Icon className="size-4" />
) : messageUi?.selectIcon ? (
<messageUi.selectIcon className="size-4" />
) : null}
<span>
{selectedItem?.label
? selectedItem.label
: (messageUi?.select ?? "Select...")}
</span>
</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
@ -75,10 +98,12 @@ export function Combobox({
: 0;
}}
>
<CommandInput
placeholder={messageUi?.placeholder ?? "Search..."}
className="h-9"
/>
{!hideSearch && (
<CommandInput
placeholder={messageUi?.placeholder ?? "Search..."}
className="h-9"
/>
)}
<CommandList>
<CommandEmpty>{messageUi?.empty ?? "Nothing found."}</CommandEmpty>
<CommandGroup>
@ -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);
}}
>
<div className="flex items-center gap-2">
{item?.Icon && <item.Icon className="ml-auto opacity-50" />}
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{/* <span className="text-xs text-muted-foreground">
{value === item.value && "ausgewählt"}
</span>
</span> */}
</div>
<Check
className={cn(

View File

@ -0,0 +1,36 @@
"use client";
import React from "react";
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 { Button } from "./ui/button";
import { PlusIcon } from "lucide-react";
function EditorDropdown() {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant={"outline"}>
<PlusIcon className="size-4" />
<span>Erstellen</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full max-w-48 space-y-2 bg-sidebar"
align="end"
>
<CreateArticleDialog cb={() => setOpen(false)} />
<CreateCategoryDialog cb={() => setOpen(false)} />
</PopoverContent>
</Popover>
);
}
export default EditorDropdown;

View File

@ -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 (
<div
className={cn(
"flex min-h-20 w-full items-center justify-center rounded-md bg-background p-4",
className,
)}
>
<Input
className="shadow-none"
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
debouncedSearch(e.currentTarget.value);
}}
placeholder="Suche Artikel oder Kategorien..."
/>
</div>
);
}
export default GlobalSearchWidget;

View File

@ -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 = {
</svg>
);
},
category: Folder,
article: Newspaper,
// socials
discord(props: LucideProps) {
return (
<svg
width="800px"
height="800px"
viewBox="0 -28.5 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
preserveAspectRatio="xMidYMid"
{...props}
>
<g>
<path
d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"
fill="#5865F2"
fillRule="nonzero"
/>
</g>
</svg>
);
},
};

View File

@ -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<typeof Sidebar> & { user?: User | null }) {
return (
<Sidebar variant="inset" className="border-r" {...props}>
<SidebarHeader>
<NavBranding subTitle="Wissen" />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
{props?.user && <NavTeamSection userRole={props.user.role} />}
<NavUser user={props.user} />{" "}
</SidebarFooter>
</Sidebar>
);
}

View File

@ -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 (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-foreground text-background">
<Icons.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{appConfig.name}</span>
<span className="truncate text-xs">{subTitle}</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}
export default NavBranding;

View File

@ -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 (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="">
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
);
}

View File

@ -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<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm">
<a href={item.url} target={item.external ? "_blank" : "_self"}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@ -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 && (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
asChild
className="hover:bg-primary hover:text-primary-foreground"
>
<Link href={"/admin"}>Admin Dashboard</Link>
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)}
</>
);
}
export default NavTeamSection;

View File

@ -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 (
<Button asChild>
<Link href={appRoutes.signin}>Anmelden</Link>
</Button>
);
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar
src={user.image}
fb={user.name}
className="h-8 w-8 rounded-lg"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar
src={user.image}
fb={user.name}
className="h-8 w-8 rounded-lg"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@ -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 (
<div className="flex h-14 items-center justify-between border-b bg-sidebar px-4">
<Input className="w-full max-w-xs" placeholder="Suche..." />
<div className="flex h-14 items-center justify-end gap-4 border-b bg-background px-4">
{isEditor && <EditorDropdown />}
<div className="flex items-center gap-4">
{isEditor && (
<Popover>
<PopoverTrigger asChild>
<Button>Erstellen</Button>
</PopoverTrigger>
<PopoverContent
className="w-full max-w-48 space-y-2 bg-sidebar"
align="end"
// side="left"
>
<CreateArticleDialog />
<CreateCategoryDialog />
</PopoverContent>
</Popover>
)}
{isAdmin && (
<Button asChild>
<Link href={appRoutes.admin.base}>Admin Dashboard</Link>
</Button>
)}
{session ? (
<Avatar
className="size-8"
src={session.user.image}
fb={session.user.name}
/>
) : (
<Button asChild>
<Link href={"/api/auth/signin"}>Anmelden</Link>
</Button>
)}
</div>
<ModeToggle />
</div>
);
}

View File

@ -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<typeof Sidebar>) {
const sidebarContent = await api.app.getSidebarContent();
return (
<Sidebar {...props}>
<SidebarHeader className="flex h-14 items-center justify-center border-b">
<Link href={"/"}>
<span className="text-2xl font-bold">{appConfig.name}</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{sidebarContent?.map((category, index) => (
<Collapsible
key={category.id}
defaultOpen={index === 1}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Link href={appRoutes.category(category.slug)}>
<span>{category.name}</span>
</Link>
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
{category?.articles?.length ? (
<CollapsibleContent>
<SidebarMenuSub>
{category.articles.map((article) => (
<SidebarMenuSubItem key={article.slug}>
<SidebarLink
title={article.title!}
url={appRoutes.article(article.slug)}
/>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="flex items-center justify-center gap-4">
<Link
className="text-xs text-muted-foreground underline"
href={"/impressum"}
>
Impressum
</Link>
<Separator orientation="vertical" />
<Link
href={"/datenschutz"}
className="text-xs text-muted-foreground underline"
>
Datenschutz
</Link>
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Hell
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dunkel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -1,7 +1,14 @@
export type AppConfig = {
name: string;
socials: {
discord: string;
};
};
export const appConfig: AppConfig = {
name: "Logipedia",
socials: {
discord: "https://discord.com",
},
};

View File

@ -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(),
});

View File

@ -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,

View File

@ -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%;