@kidecms/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
@@ -0,0 +1,804 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ type Column,
6
+ type ColumnDef,
7
+ type ColumnFiltersState,
8
+ type SortingState,
9
+ flexRender,
10
+ getCoreRowModel,
11
+ getFilteredRowModel,
12
+ getPaginationRowModel,
13
+ getSortedRowModel,
14
+ useReactTable,
15
+ } from "@tanstack/react-table";
16
+ import {
17
+ ArrowDown,
18
+ ArrowUp,
19
+ ArrowUpDown,
20
+ ChevronDown,
21
+ ChevronLeft,
22
+ ChevronRight,
23
+ Loader2,
24
+ MoreHorizontal,
25
+ Search,
26
+ } from "lucide-react";
27
+
28
+ import {
29
+ AlertDialog,
30
+ AlertDialogClose,
31
+ AlertDialogContent,
32
+ AlertDialogDescription,
33
+ AlertDialogFooter,
34
+ AlertDialogHeader,
35
+ AlertDialogTitle,
36
+ } from "./ui/alert-dialog";
37
+ import { Badge, StatusBadge } from "./ui/badge";
38
+ import { Button } from "./ui/button";
39
+ import { Checkbox } from "./ui/checkbox";
40
+ import {
41
+ DropdownMenu,
42
+ DropdownMenuContent,
43
+ DropdownMenuItem,
44
+ DropdownMenuLabel,
45
+ DropdownMenuSeparator,
46
+ DropdownMenuTrigger,
47
+ } from "./ui/dropdown-menu";
48
+ import { Input } from "./ui/input";
49
+ import { cn } from "../lib/utils";
50
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
51
+
52
+ type DataTableColumn = {
53
+ key: string;
54
+ label: string;
55
+ };
56
+
57
+ type DataTableRow = {
58
+ id: string;
59
+ editHref: string;
60
+ status?: string;
61
+ singleton?: boolean;
62
+ locales: string[];
63
+ searchText: string;
64
+ values: Record<string, string>;
65
+ };
66
+
67
+ type ServerPaginationConfig = {
68
+ totalDocs: number;
69
+ totalPages: number;
70
+ currentPage: number;
71
+ pageSize: number;
72
+ currentSort: { field: string; direction: "asc" | "desc" };
73
+ currentSearch: string;
74
+ };
75
+
76
+ type DocumentsDataTableProps = {
77
+ collectionSlug: string;
78
+ draftsEnabled?: boolean;
79
+ duplicateEnabled?: boolean;
80
+ defaultLocale?: string;
81
+ labelField?: string;
82
+ newHref?: string;
83
+ title: string;
84
+ searchPlaceholder?: string;
85
+ columns: DataTableColumn[];
86
+ data: DataTableRow[];
87
+ serverPagination?: ServerPaginationConfig;
88
+ };
89
+
90
+ const formatDateClient = (value: unknown): string => {
91
+ if (!value) return "\u2014";
92
+ const date = new Date(String(value));
93
+ if (isNaN(date.getTime())) return String(value);
94
+ return date.toLocaleDateString(undefined, {
95
+ year: "numeric",
96
+ month: "numeric",
97
+ day: "numeric",
98
+ hour: "2-digit",
99
+ minute: "2-digit",
100
+ });
101
+ };
102
+
103
+ const getVisualStatus = (d: Record<string, unknown>): string => {
104
+ const status = String(d._status ?? "draft");
105
+ if (status === "scheduled") return "scheduled";
106
+ if (status === "published" && d._published) return "changed";
107
+ return status;
108
+ };
109
+
110
+ function DataTableColumnHeader({ column, title }: { column: Column<DataTableRow, unknown>; title: string }) {
111
+ if (!column.getCanSort()) {
112
+ return <span>{title}</span>;
113
+ }
114
+
115
+ const sorted = column.getIsSorted();
116
+ const SortIcon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
117
+
118
+ return (
119
+ <Button
120
+ variant="ghost"
121
+ size="sm"
122
+ className="-ml-2 h-8 px-2 text-sm font-medium"
123
+ onClick={() => column.toggleSorting(sorted === "asc")}
124
+ >
125
+ <span>{title}</span>
126
+ <SortIcon className="text-muted-foreground size-4" />
127
+ </Button>
128
+ );
129
+ }
130
+
131
+ export default function DocumentsDataTable({
132
+ collectionSlug,
133
+ draftsEnabled = false,
134
+ duplicateEnabled = true,
135
+ defaultLocale,
136
+ labelField = "title",
137
+ newHref,
138
+ title,
139
+ searchPlaceholder = "Filter documents...",
140
+ columns,
141
+ data,
142
+ serverPagination,
143
+ }: DocumentsDataTableProps) {
144
+ const isServerMode = !!serverPagination;
145
+
146
+ const [actionError, setActionError] = React.useState<string | null>(null);
147
+ const [sorting, setSorting] = React.useState<SortingState>([]);
148
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
149
+ const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>({
150
+ search: false,
151
+ });
152
+ const [rowSelection, setRowSelection] = React.useState({});
153
+ const [isPending, startTransition] = React.useTransition();
154
+ const [deleteConfirm, setDeleteConfirm] = React.useState<DataTableRow[] | null>(null);
155
+ const [deleteRefWarning, setDeleteRefWarning] = React.useState<string | null>(null);
156
+
157
+ // Check for references when delete is triggered
158
+ React.useEffect(() => {
159
+ if (!deleteConfirm || deleteConfirm.length === 0) {
160
+ setDeleteRefWarning(null);
161
+ return;
162
+ }
163
+ (async () => {
164
+ let totalRefs = 0;
165
+ const parts: string[] = [];
166
+ for (const row of deleteConfirm) {
167
+ try {
168
+ const res = await fetch(`/api/cms/references/${collectionSlug}/${row.id}`);
169
+ if (res.ok) {
170
+ const { refs, total } = await res.json();
171
+ totalRefs += total;
172
+ for (const r of refs) {
173
+ const existing = parts.find((p) => p.includes(r.collection.toLowerCase()));
174
+ if (!existing) parts.push(`${r.count} ${r.collection.toLowerCase()}`);
175
+ }
176
+ }
177
+ } catch {}
178
+ }
179
+ if (totalRefs > 0) {
180
+ setDeleteRefWarning(`Referenced by ${parts.join(", ")}. Deleting will leave broken references.`);
181
+ } else {
182
+ setDeleteRefWarning(null);
183
+ }
184
+ })();
185
+ }, [deleteConfirm, collectionSlug]);
186
+
187
+ // Server-side pagination state
188
+ const [serverData, setServerData] = React.useState<DataTableRow[]>(data);
189
+ const [serverTotalDocs, setServerTotalDocs] = React.useState(serverPagination?.totalDocs ?? 0);
190
+ const [serverTotalPages, setServerTotalPages] = React.useState(serverPagination?.totalPages ?? 1);
191
+ const [serverPage, setServerPage] = React.useState(serverPagination?.currentPage ?? 1);
192
+ const [serverSort, setServerSort] = React.useState(serverPagination?.currentSort ?? null);
193
+ const [serverSearch, setServerSearch] = React.useState(serverPagination?.currentSearch ?? "");
194
+ const [searchInput, setSearchInput] = React.useState(serverPagination?.currentSearch ?? "");
195
+ const [isLoading, setIsLoading] = React.useState(false);
196
+ const searchTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
197
+ const pageSize = serverPagination?.pageSize ?? 20;
198
+
199
+ const fetchPage = React.useCallback(
200
+ async (page: number, sort: { field: string; direction: string } | null, search: string) => {
201
+ if (!isServerMode) return;
202
+ setIsLoading(true);
203
+ setRowSelection({});
204
+ try {
205
+ const params = new URLSearchParams();
206
+ params.set("status", "any");
207
+ params.set("limit", String(pageSize));
208
+ params.set("offset", String((page - 1) * pageSize));
209
+ if (sort) {
210
+ params.set("sort", JSON.stringify(sort));
211
+ }
212
+ if (search) {
213
+ params.set("search", search);
214
+ }
215
+
216
+ const response = await fetch(`/api/cms/${collectionSlug}?${params}`);
217
+ if (!response.ok) throw new Error("Failed to fetch");
218
+ const result = await response.json();
219
+
220
+ setServerData(
221
+ result.docs.map((entry: Record<string, unknown>) => ({
222
+ id: String(entry._id),
223
+ editHref: `/admin/${collectionSlug}/${entry._id}`,
224
+ status: getVisualStatus(entry),
225
+ locales: [
226
+ ...(defaultLocale ? [defaultLocale] : []),
227
+ ...(Array.isArray(entry._availableLocales)
228
+ ? (entry._availableLocales as string[]).filter((l) => l !== defaultLocale)
229
+ : []),
230
+ ],
231
+ searchText: String(entry[labelField] ?? entry.slug ?? entry._id ?? ""),
232
+ values: Object.fromEntries(
233
+ columns.map((column) => [
234
+ column.key,
235
+ column.key === "_status"
236
+ ? getVisualStatus(entry)
237
+ : column.key === "_updatedAt" || column.key === "_createdAt"
238
+ ? formatDateClient(entry[column.key])
239
+ : String(entry[column.key] ?? "\u2014"),
240
+ ]),
241
+ ),
242
+ })),
243
+ );
244
+ setServerTotalDocs(result.totalDocs);
245
+ setServerTotalPages(result.totalPages);
246
+ setServerPage(page);
247
+
248
+ // Update URL for bookmarkability
249
+ const url = new URL(window.location.href);
250
+ url.searchParams.set("page", String(page));
251
+ if (sort) {
252
+ url.searchParams.set("sort", sort.field);
253
+ url.searchParams.set("dir", sort.direction);
254
+ }
255
+ if (search) {
256
+ url.searchParams.set("q", search);
257
+ } else {
258
+ url.searchParams.delete("q");
259
+ }
260
+ url.searchParams.delete("_toast");
261
+ url.searchParams.delete("_msg");
262
+ window.history.replaceState({}, "", url.pathname + url.search);
263
+ } catch (error) {
264
+ console.error("Failed to fetch page:", error);
265
+ } finally {
266
+ setIsLoading(false);
267
+ }
268
+ },
269
+ [isServerMode, collectionSlug, columns, pageSize, defaultLocale, labelField],
270
+ );
271
+
272
+ // Cleanup debounce timer
273
+ React.useEffect(() => {
274
+ return () => {
275
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
276
+ };
277
+ }, []);
278
+
279
+ // "C" hotkey to create new document
280
+ React.useEffect(() => {
281
+ if (!newHref) return;
282
+ const handleKeydown = (e: KeyboardEvent) => {
283
+ if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
284
+ const tag = (e.target as HTMLElement).tagName;
285
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
286
+ if ((e.target as HTMLElement).isContentEditable) return;
287
+ window.location.assign(newHref);
288
+ }
289
+ };
290
+ document.addEventListener("keydown", handleKeydown);
291
+ return () => document.removeEventListener("keydown", handleKeydown);
292
+ }, [newHref]);
293
+
294
+ const primaryColumnKey = columns.find((column) => !column.key.startsWith("_"))?.key ?? columns[0]?.key;
295
+
296
+ const handleSearchChange = React.useCallback(
297
+ (value: string) => {
298
+ setSearchInput(value);
299
+ if (!isServerMode) return;
300
+ if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
301
+ searchTimerRef.current = setTimeout(() => {
302
+ setServerSearch(value);
303
+ void fetchPage(1, serverSort, value);
304
+ }, 300);
305
+ },
306
+ [isServerMode, serverSort, fetchPage],
307
+ );
308
+
309
+ const handleSortingChange = React.useCallback(
310
+ (updater: React.SetStateAction<SortingState>) => {
311
+ const newSorting = typeof updater === "function" ? updater(sorting) : updater;
312
+ setSorting(newSorting);
313
+
314
+ if (isServerMode && newSorting.length > 0) {
315
+ const sort = {
316
+ field: newSorting[0].id,
317
+ direction: (newSorting[0].desc ? "desc" : "asc") as "asc" | "desc",
318
+ };
319
+ setServerSort(sort);
320
+ void fetchPage(1, sort, serverSearch);
321
+ }
322
+ },
323
+ [isServerMode, sorting, serverSearch, fetchPage],
324
+ );
325
+
326
+ const runAction = React.useCallback(
327
+ async (action: "publish" | "unpublish" | "delete" | "duplicate", rows: DataTableRow[]) => {
328
+ if (!rows.length) {
329
+ return;
330
+ }
331
+
332
+ setActionError(null);
333
+
334
+ try {
335
+ // Duplicate action: create a copy and redirect to its edit page
336
+ if (action === "duplicate") {
337
+ const row = rows[0];
338
+ const rowCollection = row.editHref.split("/")[2] ?? collectionSlug;
339
+ const response = await fetch(`/api/cms/${rowCollection}/${row.id}/duplicate`, {
340
+ method: "POST",
341
+ headers: { Accept: "application/json" },
342
+ });
343
+ if (!response.ok) throw new Error("Failed to duplicate document.");
344
+ const created = await response.json();
345
+ window.location.assign(`/admin/${rowCollection}/${created._id}?_toast=success&_msg=Document+duplicated`);
346
+ return;
347
+ }
348
+
349
+ await Promise.all(
350
+ rows.map(async (row) => {
351
+ // Extract collection slug from editHref (/admin/{slug}/{id})
352
+ const rowCollection = row.editHref.split("/")[2] ?? collectionSlug;
353
+ const endpoint =
354
+ action === "delete"
355
+ ? `/api/cms/${rowCollection}/${row.id}`
356
+ : `/api/cms/${rowCollection}/${row.id}/${action}`;
357
+
358
+ const response = await fetch(endpoint, {
359
+ method: action === "delete" ? "DELETE" : "POST",
360
+ headers: {
361
+ Accept: "application/json",
362
+ },
363
+ });
364
+
365
+ if (!response.ok) {
366
+ throw new Error(`Failed to ${action} document.`);
367
+ }
368
+ }),
369
+ );
370
+
371
+ if (isServerMode) {
372
+ // Re-fetch current page after action
373
+ const targetPage = action === "delete" ? 1 : serverPage;
374
+ await fetchPage(targetPage, serverSort, serverSearch);
375
+ return;
376
+ }
377
+
378
+ // Reload with toast params so the server-side Toast component renders
379
+ const count = rows.length;
380
+ const label = count === 1 ? "document" : "documents";
381
+ const pastTense = action === "publish" ? "published" : action === "unpublish" ? "unpublished" : "deleted";
382
+ const msg = `${count} ${label} ${pastTense}`;
383
+ const url = new URL(window.location.href);
384
+ url.searchParams.set("_toast", "success");
385
+ url.searchParams.set("_msg", msg);
386
+ window.location.assign(url.pathname + url.search);
387
+ } catch (error) {
388
+ const msg = error instanceof Error ? error.message : "Action failed";
389
+ if (isServerMode) {
390
+ setActionError(msg);
391
+ return;
392
+ }
393
+ const url = new URL(window.location.href);
394
+ url.searchParams.set("_toast", "error");
395
+ url.searchParams.set("_msg", msg);
396
+ window.location.assign(url.pathname + url.search);
397
+ }
398
+ },
399
+ [collectionSlug, isServerMode, serverPage, serverSort, serverSearch, fetchPage],
400
+ );
401
+
402
+ const tableColumns = React.useMemo<ColumnDef<DataTableRow>[]>(
403
+ () => [
404
+ {
405
+ id: "select",
406
+ header: ({ table }) => (
407
+ <Checkbox
408
+ checked={table.getIsAllPageRowsSelected()}
409
+ indeterminate={table.getIsSomePageRowsSelected()}
410
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
411
+ aria-label="Select all"
412
+ />
413
+ ),
414
+ cell: ({ row }) => (
415
+ <Checkbox
416
+ checked={row.getIsSelected()}
417
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
418
+ aria-label="Select row"
419
+ />
420
+ ),
421
+ enableSorting: false,
422
+ enableHiding: false,
423
+ },
424
+ ...(!isServerMode
425
+ ? [
426
+ {
427
+ id: "search",
428
+ accessorFn: (row: DataTableRow) =>
429
+ [row.searchText, ...Object.values(row.values), row.locales.join(" ")].join(" ").toLowerCase(),
430
+ header: () => null,
431
+ cell: () => null,
432
+ enableSorting: false,
433
+ enableHiding: false,
434
+ } as ColumnDef<DataTableRow>,
435
+ ]
436
+ : []),
437
+ ...columns.map<ColumnDef<DataTableRow>>((column) => ({
438
+ accessorFn: (row) => row.values[column.key] ?? "",
439
+ id: column.key,
440
+ header: ({ column: headerColumn }) => <DataTableColumnHeader column={headerColumn} title={column.label} />,
441
+ cell: ({ row }) => {
442
+ const value = row.original.values[column.key] ?? "\u2014";
443
+ if (column.key === "_status") {
444
+ return (
445
+ <StatusBadge
446
+ status={row.original.status ?? value}
447
+ className="text-muted-foreground text-sm font-normal"
448
+ />
449
+ );
450
+ }
451
+ const isPrimary = column.key === primaryColumnKey;
452
+ return (
453
+ <>
454
+ {isPrimary ? (
455
+ <a
456
+ href={row.original.editHref}
457
+ className="text-foreground font-medium underline-offset-2 hover:underline"
458
+ >
459
+ {value}
460
+ </a>
461
+ ) : (
462
+ <div className="text-muted-foreground">{value}</div>
463
+ )}
464
+ </>
465
+ );
466
+ },
467
+ })),
468
+ {
469
+ accessorFn: (row) => row.locales.join(", "),
470
+ id: "locales",
471
+ header: ({ column }) => <DataTableColumnHeader column={column} title="Locales" />,
472
+ cell: ({ row }) => (
473
+ <div className="flex flex-wrap gap-1.5">
474
+ {row.original.locales.map((locale) => (
475
+ <a key={locale} href={`${row.original.editHref}?locale=${locale}`} onClick={(e) => e.stopPropagation()}>
476
+ <Badge
477
+ variant="outline"
478
+ className="text-muted-foreground hover:border-foreground/50 hover:text-foreground uppercase transition-colors"
479
+ >
480
+ {locale}
481
+ </Badge>
482
+ </a>
483
+ ))}
484
+ </div>
485
+ ),
486
+ },
487
+ {
488
+ id: "actions",
489
+ enableSorting: false,
490
+ enableHiding: false,
491
+ cell: ({ row }) => (
492
+ <div className="flex justify-end">
493
+ <DropdownMenu>
494
+ <DropdownMenuTrigger asChild>
495
+ <Button variant="ghost" size="icon-sm" className="rounded-md" aria-label="Open actions menu">
496
+ <MoreHorizontal />
497
+ <span className="sr-only">Open menu</span>
498
+ </Button>
499
+ </DropdownMenuTrigger>
500
+ <DropdownMenuContent align="end" className="w-44">
501
+ <DropdownMenuLabel>Actions</DropdownMenuLabel>
502
+ <DropdownMenuSeparator />
503
+ <DropdownMenuItem onClick={() => window.location.assign(row.original.editHref)}>
504
+ Edit document
505
+ </DropdownMenuItem>
506
+ {duplicateEnabled && !row.original.singleton && (
507
+ <DropdownMenuItem
508
+ disabled={isPending}
509
+ onClick={() =>
510
+ startTransition(() => {
511
+ void runAction("duplicate", [row.original]);
512
+ })
513
+ }
514
+ >
515
+ Duplicate
516
+ </DropdownMenuItem>
517
+ )}
518
+ {draftsEnabled && (
519
+ <>
520
+ <DropdownMenuItem
521
+ disabled={isPending || row.original.status === "published"}
522
+ onClick={() =>
523
+ startTransition(() => {
524
+ void runAction("publish", [row.original]);
525
+ })
526
+ }
527
+ >
528
+ Publish
529
+ </DropdownMenuItem>
530
+ <DropdownMenuItem
531
+ disabled={isPending || row.original.status === "draft"}
532
+ onClick={() =>
533
+ startTransition(() => {
534
+ void runAction("unpublish", [row.original]);
535
+ })
536
+ }
537
+ >
538
+ Unpublish
539
+ </DropdownMenuItem>
540
+ </>
541
+ )}
542
+ <DropdownMenuSeparator />
543
+ <DropdownMenuItem
544
+ variant="destructive"
545
+ disabled={isPending}
546
+ onClick={() => setDeleteConfirm([row.original])}
547
+ >
548
+ Delete
549
+ </DropdownMenuItem>
550
+ </DropdownMenuContent>
551
+ </DropdownMenu>
552
+ </div>
553
+ ),
554
+ },
555
+ ],
556
+ [columns, draftsEnabled, duplicateEnabled, isPending, primaryColumnKey, isServerMode, runAction],
557
+ );
558
+
559
+ const table = useReactTable({
560
+ data: isServerMode ? serverData : data,
561
+ columns: tableColumns,
562
+ getRowId: (row) => row.id,
563
+ enableRowSelection: true,
564
+ onSortingChange: isServerMode ? handleSortingChange : setSorting,
565
+ onColumnFiltersChange: setColumnFilters,
566
+ onColumnVisibilityChange: setColumnVisibility,
567
+ onRowSelectionChange: setRowSelection,
568
+ getCoreRowModel: getCoreRowModel(),
569
+ ...(isServerMode
570
+ ? {
571
+ manualPagination: true,
572
+ manualSorting: true,
573
+ manualFiltering: true,
574
+ pageCount: serverTotalPages,
575
+ }
576
+ : {
577
+ getPaginationRowModel: getPaginationRowModel(),
578
+ getSortedRowModel: getSortedRowModel(),
579
+ getFilteredRowModel: getFilteredRowModel(),
580
+ }),
581
+ state: {
582
+ sorting,
583
+ columnFilters,
584
+ columnVisibility,
585
+ rowSelection,
586
+ ...(isServerMode ? { pagination: { pageIndex: serverPage - 1, pageSize } } : {}),
587
+ },
588
+ });
589
+
590
+ const searchColumn = !isServerMode ? table.getColumn("search") : null;
591
+ const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
592
+ const displayedTotal = isServerMode ? serverTotalDocs : table.getFilteredRowModel().rows.length;
593
+ const displayedPageCount = isServerMode ? serverTotalPages : table.getPageCount();
594
+ const displayedPageIndex = isServerMode ? serverPage : table.getState().pagination.pageIndex + 1;
595
+
596
+ return (
597
+ <div className="space-y-4">
598
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
599
+ <div className="flex items-center gap-4">
600
+ <div className="relative w-full sm:w-80">
601
+ <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
602
+ <Input
603
+ placeholder={searchPlaceholder}
604
+ value={isServerMode ? searchInput : ((searchColumn?.getFilterValue() as string) ?? "")}
605
+ onChange={(event) => {
606
+ const value = event.target.value;
607
+ if (isServerMode) {
608
+ handleSearchChange(value);
609
+ } else {
610
+ searchColumn?.setFilterValue(value);
611
+ }
612
+ }}
613
+ className="pl-9 text-sm"
614
+ />
615
+ </div>
616
+ <div className="text-muted-foreground hidden text-sm md:block">
617
+ {isLoading ? (
618
+ <Loader2 className="size-4 animate-spin" />
619
+ ) : (
620
+ <>
621
+ {displayedTotal} {title.toLowerCase()}
622
+ </>
623
+ )}
624
+ </div>
625
+ </div>
626
+
627
+ <div className="flex flex-wrap items-center gap-2">
628
+ {selectedRows.length > 0 && (
629
+ <DropdownMenu>
630
+ <DropdownMenuTrigger asChild>
631
+ <Button variant="outline" size="sm" disabled={isPending}>
632
+ {isPending ? "Working..." : `${selectedRows.length} selected`}
633
+ <ChevronDown className="size-4" />
634
+ </Button>
635
+ </DropdownMenuTrigger>
636
+ <DropdownMenuContent align="end" className="w-52">
637
+ <DropdownMenuLabel>Selected actions</DropdownMenuLabel>
638
+ <DropdownMenuSeparator />
639
+ {selectedRows.length === 1 && (
640
+ <DropdownMenuItem onClick={() => window.location.assign(selectedRows[0].editHref)}>
641
+ Edit document
642
+ </DropdownMenuItem>
643
+ )}
644
+ {draftsEnabled && (
645
+ <>
646
+ <DropdownMenuItem
647
+ disabled={isPending}
648
+ onClick={() =>
649
+ startTransition(() => {
650
+ void runAction("publish", selectedRows);
651
+ })
652
+ }
653
+ >
654
+ Publish selected
655
+ </DropdownMenuItem>
656
+ <DropdownMenuItem
657
+ disabled={isPending}
658
+ onClick={() =>
659
+ startTransition(() => {
660
+ void runAction("unpublish", selectedRows);
661
+ })
662
+ }
663
+ >
664
+ Unpublish selected
665
+ </DropdownMenuItem>
666
+ </>
667
+ )}
668
+ <DropdownMenuSeparator />
669
+ <DropdownMenuItem
670
+ variant="destructive"
671
+ disabled={isPending}
672
+ onClick={() => setDeleteConfirm(selectedRows)}
673
+ >
674
+ Delete selected
675
+ </DropdownMenuItem>
676
+ </DropdownMenuContent>
677
+ </DropdownMenu>
678
+ )}
679
+ </div>
680
+ </div>
681
+
682
+ {actionError && (
683
+ <div className="border-destructive/30 bg-destructive/5 text-destructive rounded-md border px-4 py-3 text-sm">
684
+ {actionError}
685
+ </div>
686
+ )}
687
+
688
+ <div className={cn("rounded-lg border", isLoading && "opacity-60")}>
689
+ <Table>
690
+ <TableHeader>
691
+ {table.getHeaderGroups().map((headerGroup) => (
692
+ <TableRow key={headerGroup.id}>
693
+ {headerGroup.headers
694
+ .filter((header) => header.column.id !== "search")
695
+ .map((header) => (
696
+ <TableHead key={header.id} className={header.column.id === "select" ? "w-10" : undefined}>
697
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
698
+ </TableHead>
699
+ ))}
700
+ </TableRow>
701
+ ))}
702
+ </TableHeader>
703
+ <TableBody>
704
+ {table.getRowModel().rows.length ? (
705
+ table.getRowModel().rows.map((row) => (
706
+ <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
707
+ {row
708
+ .getVisibleCells()
709
+ .filter((cell) => cell.column.id !== "search")
710
+ .map((cell) => (
711
+ <TableCell key={cell.id} className={cell.column.id === "select" ? "w-10" : undefined}>
712
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
713
+ </TableCell>
714
+ ))}
715
+ </TableRow>
716
+ ))
717
+ ) : (
718
+ <TableRow>
719
+ <TableCell colSpan={tableColumns.length - 1} className="text-muted-foreground h-24 text-center">
720
+ No documents found.
721
+ </TableCell>
722
+ </TableRow>
723
+ )}
724
+ </TableBody>
725
+ </Table>
726
+ </div>
727
+
728
+ {displayedPageCount > 1 && (
729
+ <div className="flex items-center justify-end px-1">
730
+ <div className="flex items-center gap-2">
731
+ <Button
732
+ variant="outline"
733
+ size="sm"
734
+ onClick={() => {
735
+ if (isServerMode) {
736
+ void fetchPage(serverPage - 1, serverSort, serverSearch);
737
+ } else {
738
+ table.previousPage();
739
+ }
740
+ }}
741
+ disabled={isServerMode ? serverPage <= 1 || isLoading : !table.getCanPreviousPage()}
742
+ >
743
+ <ChevronLeft className="size-4" />
744
+ Previous
745
+ </Button>
746
+ <div className="text-muted-foreground text-sm">
747
+ Page {displayedPageIndex} of {displayedPageCount}
748
+ </div>
749
+ <Button
750
+ variant="outline"
751
+ size="sm"
752
+ onClick={() => {
753
+ if (isServerMode) {
754
+ void fetchPage(serverPage + 1, serverSort, serverSearch);
755
+ } else {
756
+ table.nextPage();
757
+ }
758
+ }}
759
+ disabled={isServerMode ? serverPage >= serverTotalPages || isLoading : !table.getCanNextPage()}
760
+ >
761
+ Next
762
+ <ChevronRight className="size-4" />
763
+ </Button>
764
+ </div>
765
+ </div>
766
+ )}
767
+ <AlertDialog open={deleteConfirm !== null} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
768
+ <AlertDialogContent>
769
+ <AlertDialogHeader>
770
+ <AlertDialogTitle>
771
+ {deleteConfirm && deleteConfirm.length === 1 ? "Delete document" : "Delete documents"}
772
+ </AlertDialogTitle>
773
+ <AlertDialogDescription>
774
+ {deleteRefWarning && (
775
+ <span className="mb-2 block font-medium text-amber-600 dark:text-amber-400">{deleteRefWarning}</span>
776
+ )}
777
+ {deleteConfirm && deleteConfirm.length === 1
778
+ ? "This action cannot be undone. This will permanently delete this document."
779
+ : `This action cannot be undone. This will permanently delete ${deleteConfirm?.length ?? 0} documents.`}
780
+ </AlertDialogDescription>
781
+ </AlertDialogHeader>
782
+ <AlertDialogFooter>
783
+ <AlertDialogClose>
784
+ <Button variant="outline">Cancel</Button>
785
+ </AlertDialogClose>
786
+ <Button
787
+ variant="destructive"
788
+ onClick={() => {
789
+ if (deleteConfirm) {
790
+ startTransition(() => {
791
+ void runAction("delete", deleteConfirm);
792
+ });
793
+ }
794
+ setDeleteConfirm(null);
795
+ }}
796
+ >
797
+ Delete
798
+ </Button>
799
+ </AlertDialogFooter>
800
+ </AlertDialogContent>
801
+ </AlertDialog>
802
+ </div>
803
+ );
804
+ }