Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2971fb298e
|
|||
|
d75501860d
|
|||
|
ff886d3002
|
15
bun.lock
15
bun.lock
@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "admin.gaertan.art",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.974.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.974.0",
|
||||
"@aws-sdk/client-s3": "^3.980.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.980.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@ -39,7 +39,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.4.17",
|
||||
"better-auth": "^1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@ -50,9 +50,9 @@
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-vibrant": "^4.0.3",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.17.2",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.18.0",
|
||||
"platejs": "^52.0.17",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
@ -65,6 +65,7 @@
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@ -73,7 +74,7 @@
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/date-fns": "^2.6.3",
|
||||
"@types/node": "^20.19.30",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "19.2.10",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@ -71,6 +71,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -5,8 +5,7 @@ import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
@ -48,6 +47,7 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -64,6 +64,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
|
||||
import { useArtworksTableStore } from "@/stores/artworksTableStore";
|
||||
import { MultiSelectFilter } from "./MultiSelectFilter";
|
||||
|
||||
// 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: {
|
||||
id?: string;
|
||||
value: TriState;
|
||||
onChange: (v: TriState) => void;
|
||||
}) {
|
||||
@ -149,7 +151,7 @@ function TriSelectInline(props: {
|
||||
value={props.value}
|
||||
onValueChange={(v) => props.onChange(v as TriState)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectTrigger id={props.id} className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -163,40 +165,27 @@ function TriSelectInline(props: {
|
||||
|
||||
type Filters = {
|
||||
name: string;
|
||||
slug: string;
|
||||
published: TriState;
|
||||
nsfw: TriState;
|
||||
needsWork: TriState;
|
||||
albumIds: string[];
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
export function ArtworksTable() {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "updatedAt", desc: true },
|
||||
]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
name: "",
|
||||
slug: "",
|
||||
published: "any",
|
||||
nsfw: "any",
|
||||
needsWork: "any",
|
||||
albumIds: [],
|
||||
categoryIds: [],
|
||||
});
|
||||
const sorting = useArtworksTableStore((s) => s.sorting);
|
||||
const setSorting = useArtworksTableStore((s) => s.setSorting);
|
||||
const pageIndex = useArtworksTableStore((s) => s.pageIndex);
|
||||
const setPageIndex = useArtworksTableStore((s) => s.setPageIndex);
|
||||
const pageSize = useArtworksTableStore((s) => s.pageSize);
|
||||
const setPageSize = useArtworksTableStore((s) => s.setPageSize);
|
||||
const filters = useArtworksTableStore((s) => s.filters) as Filters;
|
||||
const setFilters = useArtworksTableStore((s) => s.setFilters);
|
||||
const resetTable = useArtworksTableStore((s) => s.reset);
|
||||
|
||||
const debouncedName = useDebouncedValue(filters.name, 300);
|
||||
const debouncedSlug = useDebouncedValue(filters.slug, 300);
|
||||
|
||||
const [rows, setRows] = useState<ArtworkTableRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const [albumOptions, setAlbumOptions] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
@ -214,7 +203,6 @@ export function ArtworksTable() {
|
||||
useEffect(() => {
|
||||
startTransition(async () => {
|
||||
const res = await getArtworkFilterOptions();
|
||||
setAlbumOptions(res.albums);
|
||||
setCategoryOptions(res.categories);
|
||||
});
|
||||
}, []);
|
||||
@ -308,33 +296,6 @@ export function ArtworksTable() {
|
||||
</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",
|
||||
header: ({ column }) => (
|
||||
@ -436,9 +397,8 @@ export function ArtworksTable() {
|
||||
manualSorting: true,
|
||||
pageCount,
|
||||
onSortingChange: (updater) => {
|
||||
setSorting((prev) =>
|
||||
typeof updater === "function" ? updater(prev) : updater,
|
||||
);
|
||||
const next = typeof updater === "function" ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
setPageIndex(0);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@ -451,10 +411,8 @@ export function ArtworksTable() {
|
||||
sorting,
|
||||
filters: {
|
||||
name: debouncedName || undefined,
|
||||
slug: debouncedSlug || undefined,
|
||||
published: filters.published,
|
||||
needsWork: filters.needsWork,
|
||||
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
|
||||
categoryIds: filters.categoryIds.length
|
||||
? filters.categoryIds
|
||||
: undefined,
|
||||
@ -469,17 +427,120 @@ export function ArtworksTable() {
|
||||
pageSize,
|
||||
sorting,
|
||||
debouncedName,
|
||||
debouncedSlug,
|
||||
filters.published,
|
||||
filters.needsWork,
|
||||
filters.albumIds,
|
||||
filters.categoryIds,
|
||||
]);
|
||||
|
||||
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 (
|
||||
<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="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" />
|
||||
@ -501,80 +562,6 @@ export function ArtworksTable() {
|
||||
</TableHead>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<TableBody>
|
||||
@ -718,12 +705,8 @@ export function ArtworksTable() {
|
||||
sorting,
|
||||
filters: {
|
||||
name: debouncedName || undefined,
|
||||
slug: debouncedSlug || undefined,
|
||||
published: filters.published,
|
||||
needsWork: filters.needsWork,
|
||||
albumIds: filters.albumIds.length
|
||||
? filters.albumIds
|
||||
: undefined,
|
||||
categoryIds: filters.categoryIds.length
|
||||
? filters.categoryIds
|
||||
: undefined,
|
||||
|
||||
@ -11,6 +11,7 @@ type Option = { id: string; name: string };
|
||||
|
||||
// Simple multi-select filter control for artwork filters.
|
||||
export function MultiSelectFilter(props: {
|
||||
id?: string;
|
||||
placeholder: string;
|
||||
options: Option[];
|
||||
value: string[]; // selected ids
|
||||
@ -21,7 +22,11 @@ export function MultiSelectFilter(props: {
|
||||
return (
|
||||
<Popover>
|
||||
<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">
|
||||
{props.value.length === 0
|
||||
? props.placeholder
|
||||
|
||||
@ -37,7 +37,7 @@ type RequestShapeSerializable = Omit<
|
||||
RequestShape,
|
||||
"createdAt" | "updatedAt" | "files" | "status"
|
||||
> & {
|
||||
status: CommissionStatus;
|
||||
status: CommissionStatus | string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
files: Array<Omit<RequestShape["files"][number], "createdAt"> & { createdAt: string | Date }>;
|
||||
|
||||
74
src/stores/artworksTableStore.ts
Normal file
74
src/stores/artworksTableStore.ts
Normal file
@ -0,0 +1,74 @@
|
||||
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