4 Commits

Author SHA1 Message Date
aa6fa39f6a Add about page 2026-02-05 15:13:57 +01:00
2971fb298e Change artwork table to be persistent in its filters and sorting 2026-02-04 12:39:25 +01:00
d75501860d Refactor Artwork list table 2026-02-04 00:23:50 +01:00
ff886d3002 Fix type error 2026-02-03 16:42:13 +01:00
15 changed files with 505 additions and 147 deletions

View File

@ -5,8 +5,8 @@
"": { "": {
"name": "admin.gaertan.art", "name": "admin.gaertan.art",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.974.0", "@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/s3-request-presigner": "^3.974.0", "@aws-sdk/s3-request-presigner": "^3.980.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -39,7 +39,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"better-auth": "^1.4.17", "better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -50,9 +50,9 @@
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-vibrant": "^4.0.3", "node-vibrant": "^4.0.4",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.13",
"pg": "^8.17.2", "pg": "^8.18.0",
"platejs": "^52.0.17", "platejs": "^52.0.17",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
@ -65,6 +65,7 @@
"tailwind-scrollbar-hide": "^4.0.0", "tailwind-scrollbar-hide": "^4.0.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.8",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",
@ -73,7 +74,7 @@
"@types/culori": "^4.0.1", "@types/culori": "^4.0.1",
"@types/date-fns": "^2.6.3", "@types/date-fns": "^2.6.3",
"@types/node": "^20.19.30", "@types/node": "^20.19.30",
"@types/nodemailer": "^7.0.5", "@types/nodemailer": "^7.0.9",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "19.2.10", "@types/react": "19.2.10",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",

View File

@ -71,6 +71,7 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-scrollbar-hide": "^4.0.0", "tailwind-scrollbar-hide": "^4.0.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zustand": "^5.0.8",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "About" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"markdown" TEXT NOT NULL,
"version" SERIAL NOT NULL,
CONSTRAINT "About_pkey" PRIMARY KEY ("id")
);

View File

@ -524,6 +524,15 @@ model TermsOfService {
version Int @default(autoincrement()) version Int @default(autoincrement())
} }
model About {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
markdown String
version Int @default(autoincrement())
}
model User { model User {
id String @id id String @id
name String name String

View File

@ -0,0 +1,11 @@
"use server";
import { prisma } from "@/lib/prisma";
// Returns the most recent About Me markdown.
export async function getLatestAboutMe(): Promise<string | null> {
const about = await prisma.about.findFirst({
orderBy: { createdAt: "desc" },
});
return about?.markdown ?? null;
}

View File

@ -0,0 +1,55 @@
"use server";
import { s3 } from "@/lib/s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
const PREFIX = "about/images/";
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}
function sanitizeFilename(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export type AboutImageUpload = {
key: string;
url: string;
size: number;
lastModified: string;
};
export async function uploadAboutImage(
formData: FormData
): Promise<AboutImageUpload> {
const file = formData.get("file");
if (!(file instanceof File)) {
throw new Error("Missing file");
}
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are allowed");
}
const safeName = sanitizeFilename(file.name || "about-image");
const key = `${PREFIX}${Date.now()}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
return {
key,
url: buildImageUrl(key),
size: file.size,
lastModified: new Date().toISOString(),
};
}

View File

@ -0,0 +1,12 @@
"use server";
import { prisma } from "@/lib/prisma";
// Saves a new About Me version.
export async function saveAboutMeAction(markdown: string) {
await prisma.about.create({
data: {
markdown,
},
});
}

View File

@ -0,0 +1,18 @@
import { getLatestAboutMe } from "@/actions/about/getAbout";
import AboutEditor from "@/components/about/Editor";
// Admin page for editing About Me content.
export default async function AboutPage() {
const markdown = await getLatestAboutMe();
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">About Me</h1>
</div>
<div className="space-y-4 p-1 border rounded-xl bg-muted/20">
<AboutEditor markdown={markdown} />
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
"use client";
import { uploadAboutImage } from "@/actions/about/images";
import { saveAboutMeAction } from "@/actions/about/saveAbout";
import { BasicBlocksKit } from "@/components/editor/plugins/basic-blocks-kit";
import { BasicMarksKit } from "@/components/editor/plugins/basic-marks-kit";
import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
import { ListKit } from "@/components/editor/plugins/list-kit";
import { MarkdownKit } from "@/components/editor/plugins/markdown-kit";
import { Button } from "@/components/ui/button";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { BulletedListToolbarButton, NumberedListToolbarButton } from "@/components/ui/list-toolbar-button";
import { MarkToolbarButton } from "@/components/ui/mark-toolbar-button";
import { ToolbarButton } from "@/components/ui/toolbar";
import {
Bold,
Braces,
Code,
Heading1,
Heading2,
Heading3,
Image,
Italic,
Quote,
Save,
Strikethrough,
Underline,
} from "lucide-react";
import type { Value } from "platejs";
import { Plate, usePlateEditor } from "platejs/react";
import { useEffect, useRef, useState, useTransition } from "react";
const initialValue: Value = [];
// Rich text editor for About Me content with image upload.
export default function AboutEditor({ markdown }: { markdown: string | null }) {
const [isPending, startTransition] = useTransition();
const [lastUploadUrl, setLastUploadUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const editor = usePlateEditor({
plugins: [
...BasicBlocksKit,
...CodeBlockKit,
...ListKit,
...BasicMarksKit,
...MarkdownKit,
],
value: initialValue,
});
useEffect(() => {
if (markdown && editor.api.markdown.deserialize) {
const markdownValue = editor.api.markdown.deserialize(markdown);
editor.children = markdownValue;
}
}, [editor, markdown]);
const handleSave = async () => {
if (!editor.api.markdown.serialize) return;
const nextMarkdown = editor.api.markdown.serialize();
await saveAboutMeAction(nextMarkdown);
};
const insertImageMarkdown = (url: string, altText?: string) => {
const alt = altText?.trim() || "image";
const snippet = `\n\n![${alt}](${url})\n\n`;
if (editor.api.markdown.deserialize) {
const nodes = editor.api.markdown.deserialize(snippet) as Value;
if (nodes.length) {
editor.tf.insertNodes(nodes);
return;
}
}
editor.tf.insertText(snippet);
};
const handleUpload = (file: File) => {
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const item = await uploadAboutImage(fd);
setLastUploadUrl(item.url);
insertImageMarkdown(item.url, file.name.replace(/\.[^.]+$/, ""));
});
};
return (
<Plate editor={editor}>
<div className="px-4 pt-4 flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.currentTarget.value = "";
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isPending}
>
Upload image
</Button>
{lastUploadUrl ? (
<div className="text-xs text-muted-foreground">
Inserted image: {lastUploadUrl}
</div>
) : null}
</div>
</div>
<FixedToolbar className="justify-start rounded-t-lg">
{/* Blocks */}
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h2.toggle()} tooltip="Heading 2">
<Heading2 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h3.toggle()} tooltip="Heading 3">
<Heading3 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.blockquote.toggle()} tooltip="Blockquote">
<Quote className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.code_block.toggle()} tooltip="Code Block">
<Braces className="w-4 h-4" />
</ToolbarButton>
<BulletedListToolbarButton />
<NumberedListToolbarButton />
{/* Mark Toolbar Buttons */}
<MarkToolbarButton nodeType="bold" tooltip="Bold">
<Bold className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic">
<Italic className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline">
<Underline className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="strikethrough" tooltip="Strikethrough">
<Strikethrough className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="code" tooltip="Code">
<Code className="w-4 h-4" />
</MarkToolbarButton>
{/* Image Upload */}
<ToolbarButton onClick={() => fileInputRef.current?.click()} tooltip="Insert Image">
<Image className="w-4 h-4" />
</ToolbarButton>
{/* Save Button */}
<ToolbarButton onClick={handleSave} tooltip="Save">
<Save className="w-4 h-4" />
</ToolbarButton>
</FixedToolbar>
<EditorContainer>
<Editor placeholder="Write your about me content..." />
</EditorContainer>
</Plate>
);
}

View File

@ -5,8 +5,7 @@ import {
type ColumnDef, type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
type SortingState, useReactTable
useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
ArrowUpDown, ArrowUpDown,
@ -48,6 +47,7 @@ import {
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card"; } from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -64,6 +64,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema"; import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
import { useArtworksTableStore } from "@/stores/artworksTableStore";
import { MultiSelectFilter } from "./MultiSelectFilter"; import { MultiSelectFilter } from "./MultiSelectFilter";
// Client-side table for filtering, sorting, and managing artworks. // Client-side table for filtering, sorting, and managing artworks.
@ -141,6 +142,7 @@ function Chips(props: { items: { id: string; name: string }[]; max?: number }) {
} }
function TriSelectInline(props: { function TriSelectInline(props: {
id?: string;
value: TriState; value: TriState;
onChange: (v: TriState) => void; onChange: (v: TriState) => void;
}) { }) {
@ -149,7 +151,7 @@ function TriSelectInline(props: {
value={props.value} value={props.value}
onValueChange={(v) => props.onChange(v as TriState)} onValueChange={(v) => props.onChange(v as TriState)}
> >
<SelectTrigger className="h-9"> <SelectTrigger id={props.id} className="h-9">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -163,40 +165,27 @@ function TriSelectInline(props: {
type Filters = { type Filters = {
name: string; name: string;
slug: string;
published: TriState; published: TriState;
nsfw: TriState;
needsWork: TriState; needsWork: TriState;
albumIds: string[];
categoryIds: string[]; categoryIds: string[];
}; };
export function ArtworksTable() { export function ArtworksTable() {
const [sorting, setSorting] = useState<SortingState>([ const sorting = useArtworksTableStore((s) => s.sorting);
{ id: "updatedAt", desc: true }, const setSorting = useArtworksTableStore((s) => s.setSorting);
]); const pageIndex = useArtworksTableStore((s) => s.pageIndex);
const [pageIndex, setPageIndex] = useState(0); const setPageIndex = useArtworksTableStore((s) => s.setPageIndex);
const [pageSize, setPageSize] = useState(25); const pageSize = useArtworksTableStore((s) => s.pageSize);
const setPageSize = useArtworksTableStore((s) => s.setPageSize);
const [filters, setFilters] = useState<Filters>({ const filters = useArtworksTableStore((s) => s.filters) as Filters;
name: "", const setFilters = useArtworksTableStore((s) => s.setFilters);
slug: "", const resetTable = useArtworksTableStore((s) => s.reset);
published: "any",
nsfw: "any",
needsWork: "any",
albumIds: [],
categoryIds: [],
});
const debouncedName = useDebouncedValue(filters.name, 300); const debouncedName = useDebouncedValue(filters.name, 300);
const debouncedSlug = useDebouncedValue(filters.slug, 300);
const [rows, setRows] = useState<ArtworkTableRow[]>([]); const [rows, setRows] = useState<ArtworkTableRow[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [albumOptions, setAlbumOptions] = useState<
{ id: string; name: string }[]
>([]);
const [categoryOptions, setCategoryOptions] = useState< const [categoryOptions, setCategoryOptions] = useState<
{ id: string; name: string }[] { id: string; name: string }[]
>([]); >([]);
@ -214,7 +203,6 @@ export function ArtworksTable() {
useEffect(() => { useEffect(() => {
startTransition(async () => { startTransition(async () => {
const res = await getArtworkFilterOptions(); const res = await getArtworkFilterOptions();
setAlbumOptions(res.albums);
setCategoryOptions(res.categories); setCategoryOptions(res.categories);
}); });
}, []); }, []);
@ -308,33 +296,6 @@ export function ArtworksTable() {
</div> </div>
), ),
}, },
{
id: "gallery",
header: "Gallery",
cell: ({ row }) => (
<span className="text-sm text-foreground/80">
{row.original.gallery?.name ?? "—"}
</span>
),
enableSorting: false,
},
{
id: "albums",
header: ({ column }) => <SortHeader title="Albums #" column={column} />,
accessorKey: "albumsCount",
cell: ({ row }) => (
<div className="space-y-1">
<div className="text-sm font-medium tabular-nums">
{row.original.albumsCount}
</div>
{row.original.albums.length ? (
<Chips items={row.original.albums} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
),
},
{ {
id: "categories", id: "categories",
header: ({ column }) => ( header: ({ column }) => (
@ -436,9 +397,8 @@ export function ArtworksTable() {
manualSorting: true, manualSorting: true,
pageCount, pageCount,
onSortingChange: (updater) => { onSortingChange: (updater) => {
setSorting((prev) => const next = typeof updater === "function" ? updater(sorting) : updater;
typeof updater === "function" ? updater(prev) : updater, setSorting(next);
);
setPageIndex(0); setPageIndex(0);
}, },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@ -451,10 +411,8 @@ export function ArtworksTable() {
sorting, sorting,
filters: { filters: {
name: debouncedName || undefined, name: debouncedName || undefined,
slug: debouncedSlug || undefined,
published: filters.published, published: filters.published,
needsWork: filters.needsWork, needsWork: filters.needsWork,
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
categoryIds: filters.categoryIds.length categoryIds: filters.categoryIds.length
? filters.categoryIds ? filters.categoryIds
: undefined, : undefined,
@ -469,17 +427,120 @@ export function ArtworksTable() {
pageSize, pageSize,
sorting, sorting,
debouncedName, debouncedName,
debouncedSlug,
filters.published, filters.published,
filters.needsWork, filters.needsWork,
filters.albumIds,
filters.categoryIds, filters.categoryIds,
]); ]);
const headerGroup = table.getHeaderGroups()[0]; const headerGroup = table.getHeaderGroups()[0];
const hasChanges =
filters.name ||
filters.published !== "any" ||
filters.needsWork !== "any" ||
filters.categoryIds.length > 0 ||
pageIndex !== 0 ||
pageSize !== 25 ||
sorting.length !== 1 ||
sorting[0]?.id !== "updatedAt" ||
sorting[0]?.desc !== true;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1">
<Label
htmlFor="artworks-filter-name"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Name
</Label>
<Input
id="artworks-filter-name"
className="mt-2 h-9"
placeholder="Filter by name…"
value={filters.name}
onChange={(e) => {
setFilters((f) => ({ ...f, name: e.target.value }));
setPageIndex(0);
}}
/>
</div>
<div className="min-w-60 flex-1">
<Label
htmlFor="artworks-filter-categories"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Categories
</Label>
<div className="mt-2">
<MultiSelectFilter
id="artworks-filter-categories"
placeholder="Filter categories…"
options={categoryOptions}
value={filters.categoryIds}
onChange={(next) => {
setFilters((f) => ({ ...f, categoryIds: next }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="min-w-36">
<Label
htmlFor="artworks-filter-published"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Published
</Label>
<div className="mt-2">
<TriSelectInline
id="artworks-filter-published"
value={filters.published}
onChange={(v) => {
setFilters((f) => ({ ...f, published: v }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="min-w-36">
<Label
htmlFor="artworks-filter-needs-work"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Needs Work
</Label>
<div className="mt-2">
<TriSelectInline
id="artworks-filter-needs-work"
value={filters.needsWork}
onChange={(v) => {
setFilters((f) => ({ ...f, needsWork: v }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
className="h-9"
disabled={!hasChanges || isPending}
onClick={() => {
resetTable();
}}
>
Reset to default
</Button>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40"> <div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
<div className="relative"> <div className="relative">
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background/60 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background/60 to-transparent" />
@ -501,80 +562,6 @@ export function ArtworksTable() {
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
{/* filter row */}
<TableRow className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const colId = header.column.id;
return (
<TableHead
key={header.id}
className="border-b border-border/70 bg-muted/30 py-2"
>
{colId === "name" ? (
<Input
className="h-9"
placeholder="Filter name…"
value={filters.name}
onChange={(e) => {
setFilters((f) => ({ ...f, name: e.target.value }));
setPageIndex(0);
}}
/>
) : colId === "slug" ? (
<Input
className="h-9"
placeholder="Filter slug…"
value={filters.slug}
onChange={(e) => {
setFilters((f) => ({ ...f, slug: e.target.value }));
setPageIndex(0);
}}
/>
) : colId === "published" ? (
<TriSelectInline
value={filters.published}
onChange={(v) => {
setFilters((f) => ({ ...f, published: v }));
setPageIndex(0);
}}
/>
) : colId === "needsWork" ? (
<TriSelectInline
value={filters.needsWork}
onChange={(v) => {
setFilters((f) => ({ ...f, needsWork: v }));
setPageIndex(0);
}}
/>
) : colId === "albums" ? (
<MultiSelectFilter
placeholder="Albums…"
options={albumOptions}
value={filters.albumIds}
onChange={(next) => {
setFilters((f) => ({ ...f, albumIds: next }));
setPageIndex(0);
}}
/>
) : colId === "categories" ? (
<MultiSelectFilter
placeholder="Categories…"
options={categoryOptions}
value={filters.categoryIds}
onChange={(next) => {
setFilters((f) => ({ ...f, categoryIds: next }));
setPageIndex(0);
}}
/>
) : (
<div className="h-9" />
)}
</TableHead>
);
})}
</TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -718,12 +705,8 @@ export function ArtworksTable() {
sorting, sorting,
filters: { filters: {
name: debouncedName || undefined, name: debouncedName || undefined,
slug: debouncedSlug || undefined,
published: filters.published, published: filters.published,
needsWork: filters.needsWork, needsWork: filters.needsWork,
albumIds: filters.albumIds.length
? filters.albumIds
: undefined,
categoryIds: filters.categoryIds.length categoryIds: filters.categoryIds.length
? filters.categoryIds ? filters.categoryIds
: undefined, : undefined,

View File

@ -11,6 +11,7 @@ type Option = { id: string; name: string };
// Simple multi-select filter control for artwork filters. // Simple multi-select filter control for artwork filters.
export function MultiSelectFilter(props: { export function MultiSelectFilter(props: {
id?: string;
placeholder: string; placeholder: string;
options: Option[]; options: Option[];
value: string[]; // selected ids value: string[]; // selected ids
@ -21,7 +22,11 @@ export function MultiSelectFilter(props: {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between"> <Button
id={props.id}
variant="outline"
className="h-9 w-full justify-between"
>
<span className="truncate"> <span className="truncate">
{props.value.length === 0 {props.value.length === 0
? props.placeholder ? props.placeholder

View File

@ -37,7 +37,7 @@ type RequestShapeSerializable = Omit<
RequestShape, RequestShape,
"createdAt" | "updatedAt" | "files" | "status" "createdAt" | "updatedAt" | "files" | "status"
> & { > & {
status: CommissionStatus; status: CommissionStatus | string;
createdAt: string | Date; createdAt: string | Date;
updatedAt: string | Date; updatedAt: string | Date;
files: Array<Omit<RequestShape["files"][number], "createdAt"> & { createdAt: string | Date }>; files: Array<Omit<RequestShape["files"][number], "createdAt"> & { createdAt: string | Date }>;

View File

@ -151,6 +151,12 @@ export default function TopNav() {
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/about">About Me</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger>Users</NavigationMenuTrigger> <NavigationMenuTrigger>Users</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>

View File

@ -54,5 +54,6 @@ export const adminNav: AdminNavGroup[] = [
], ],
}, },
{ type: "link", title: "Terms of Service", href: "/tos" }, { type: "link", title: "Terms of Service", href: "/tos" },
{ type: "link", title: "About Me", href: "/about" },
{ type: "link", title: "Users", href: "/users" }, { type: "link", title: "Users", href: "/users" },
]; ];

View File

@ -0,0 +1,77 @@
import type { SortingState } from "@tanstack/react-table";
import { create } from "zustand";
import { persist } from "zustand/middleware";
const defaultSorting: SortingState = [{ id: "updatedAt", desc: true }];
type ArtworksTableState = {
sorting: SortingState;
pageIndex: number;
pageSize: number;
filters: {
name: string;
published: "any" | "true" | "false";
needsWork: "any" | "true" | "false";
categoryIds: string[];
};
setSorting: (next: SortingState) => void;
setPageIndex: (next: number | ((prev: number) => number)) => void;
setPageSize: (next: number) => void;
setFilters: (
next:
| ArtworksTableState["filters"]
| ((
prev: ArtworksTableState["filters"],
) => ArtworksTableState["filters"]),
) => void;
reset: () => void;
};
export const useArtworksTableStore = create<ArtworksTableState>()(
persist(
(set) => ({
sorting: defaultSorting,
pageIndex: 0,
pageSize: 25,
filters: {
name: "",
published: "any",
needsWork: "any",
categoryIds: [],
},
setSorting: (next) => set({ sorting: next }),
setPageIndex: (next) =>
set((state) => {
const value =
typeof next === "function" ? next(state.pageIndex) : next;
return { pageIndex: Math.max(0, value) };
}),
setPageSize: (next) => set({ pageSize: next }),
setFilters: (next) =>
set((state) => ({
filters: typeof next === "function" ? next(state.filters) : next,
})),
reset: () =>
set({
sorting: defaultSorting,
pageIndex: 0,
pageSize: 25,
filters: {
name: "",
published: "any",
needsWork: "any",
categoryIds: [],
},
}),
}),
{
name: "admin-artworks-table",
partialize: (state) => ({
sorting: state.sorting,
pageIndex: state.pageIndex,
pageSize: state.pageSize,
filters: state.filters,
}),
},
),
);