@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.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- 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
|
+
}
|