Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9571b5b649
|
|||
|
25093606c9
|
|||
|
aa6fa39f6a
|
|||
|
2971fb298e
|
|||
|
d75501860d
|
|||
|
ff886d3002
|
15
bun.lock
15
bun.lock
@ -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",
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
10
prisma/migrations/20260205135425_about_01/migration.sql
Normal file
10
prisma/migrations/20260205135425_about_01/migration.sql
Normal 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")
|
||||||
|
);
|
||||||
@ -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
|
||||||
|
|||||||
11
src/actions/about/getAbout.ts
Normal file
11
src/actions/about/getAbout.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/actions/about/images.ts
Normal file
55
src/actions/about/images.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/actions/about/saveAbout.ts
Normal file
12
src/actions/about/saveAbout.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/app/(admin)/about/page.tsx
Normal file
18
src/app/(admin)/about/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/components/about/Editor.tsx
Normal file
171
src/components/about/Editor.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"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 { ImageKit } from "@/components/editor/plugins/image-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,
|
||||||
|
...ImageKit,
|
||||||
|
...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\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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }>;
|
||||||
|
|||||||
15
src/components/editor/plugins/image-kit.tsx
Normal file
15
src/components/editor/plugins/image-kit.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createPlatePlugin } from 'platejs/react';
|
||||||
|
|
||||||
|
import { ImageElement } from '@/components/ui/image-node';
|
||||||
|
|
||||||
|
// Minimal image plugin to render markdown images.
|
||||||
|
export const ImageKit = [
|
||||||
|
createPlatePlugin({
|
||||||
|
key: 'img',
|
||||||
|
node: {
|
||||||
|
isElement: true,
|
||||||
|
},
|
||||||
|
}).withComponent(ImageElement),
|
||||||
|
];
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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" },
|
||||||
];
|
];
|
||||||
|
|||||||
77
src/components/ui/image-node.tsx
Normal file
77
src/components/ui/image-node.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
import type { PlateElementProps } from 'platejs/react';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import {
|
||||||
|
PlateElement,
|
||||||
|
useEditorRef,
|
||||||
|
useElement,
|
||||||
|
useReadOnly
|
||||||
|
} from 'platejs/react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type ImageElementData = {
|
||||||
|
url?: string;
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
altText?: string;
|
||||||
|
caption?: { text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageElement(props: PlateElementProps) {
|
||||||
|
const editor = useEditorRef();
|
||||||
|
const element = useElement();
|
||||||
|
const readOnly = useReadOnly();
|
||||||
|
const data = (element ?? props.element) as ImageElementData;
|
||||||
|
const src = data.url ?? data.src;
|
||||||
|
const captionText =
|
||||||
|
data.caption?.map((c) => c.text).join('') ?? data.alt ?? data.altText ?? '';
|
||||||
|
const showCaptionEditor = !readOnly;
|
||||||
|
const path = showCaptionEditor ? editor.api.findPath(element ?? props.element) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlateElement {...props} className={cn('my-4')}>
|
||||||
|
<figure className="w-full">
|
||||||
|
{src && typeof src === 'string' ? (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={captionText}
|
||||||
|
width={1200}
|
||||||
|
height={800}
|
||||||
|
className="max-w-full h-auto rounded-md border border-border"
|
||||||
|
contentEditable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">Missing image source</div>
|
||||||
|
)}
|
||||||
|
<figcaption
|
||||||
|
className="mt-2"
|
||||||
|
contentEditable={false}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showCaptionEditor ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={captionText}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!path) return;
|
||||||
|
editor.tf.setNodes({ caption: [{ text: e.target.value }] }, { at: path });
|
||||||
|
}}
|
||||||
|
placeholder="Add caption / alt text"
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onFocus={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : captionText ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{captionText}</div>
|
||||||
|
) : null}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
{props.children}
|
||||||
|
</PlateElement>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/stores/artworksTableStore.ts
Normal file
77
src/stores/artworksTableStore.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user