@kyro-cms/admin 0.1.6 → 0.1.8

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 (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
@@ -0,0 +1,890 @@
1
+ import { useState, useEffect, useMemo, useCallback } from "react";
2
+ import { Spinner } from "./ui/Spinner";
3
+ import { ConfirmModal } from "./ui/Modal";
4
+
5
+ export interface FieldConfig {
6
+ name: string;
7
+ type: string;
8
+ label?: string;
9
+ required?: boolean;
10
+ options?: { value: string; label: string }[];
11
+ fields?: FieldConfig[];
12
+ tabs?: Array<{ label?: string; name?: string; fields?: FieldConfig[] }>;
13
+ admin?: {
14
+ hidden?: boolean;
15
+ readonly?: boolean;
16
+ };
17
+ }
18
+
19
+ export interface CollectionConfig {
20
+ slug: string;
21
+ label?: string;
22
+ singularLabel?: string;
23
+ fields: FieldConfig[];
24
+ timestamps?: boolean;
25
+ admin?: {
26
+ description?: string;
27
+ defaultColumns?: string[];
28
+ useAsTitle?: string;
29
+ };
30
+ }
31
+
32
+ interface FilterConfig {
33
+ field: string;
34
+ operator:
35
+ | "equals"
36
+ | "contains"
37
+ | "gt"
38
+ | "lt"
39
+ | "gte"
40
+ | "lte"
41
+ | "between"
42
+ | "in";
43
+ value: string;
44
+ }
45
+
46
+ interface SortConfig {
47
+ field: string;
48
+ direction: "asc" | "desc";
49
+ }
50
+
51
+ interface EnhancedListViewProps {
52
+ collection: CollectionConfig;
53
+ collectionSlug: string;
54
+ initialDocs?: any[];
55
+ initialTotal?: number;
56
+ }
57
+
58
+ export function EnhancedListView({
59
+ collection,
60
+ collectionSlug,
61
+ initialDocs = [],
62
+ initialTotal = 0,
63
+ }: EnhancedListViewProps) {
64
+ const onCreate = () => (window.location.href = `/${collectionSlug}/new`);
65
+ const onEdit = (id: string) =>
66
+ (window.location.href = `/${collectionSlug}/${id}`);
67
+ const [docs, setDocs] = useState<any[]>(initialDocs);
68
+ const [totalDocs, setTotalDocs] = useState(initialTotal);
69
+ const [loading, setLoading] = useState(false);
70
+ const [page, setPage] = useState(1);
71
+ const [limit, setLimit] = useState(10);
72
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
73
+ const [search, setSearch] = useState("");
74
+ const [filters, setFilters] = useState<FilterConfig[]>([]);
75
+
76
+ const addFilter = () => {
77
+ setFilters([...filters, { field: "", operator: "equals", value: "" }]);
78
+ };
79
+
80
+ const clearAll = () => {
81
+ setSearch("");
82
+ setFilters([]);
83
+ setSort(null);
84
+ };
85
+
86
+ const removeFilter = (index: number) => {
87
+ setFilters(filters.filter((_, i) => i !== index));
88
+ };
89
+
90
+ const updateFilter = (index: number, updates: Partial<FilterConfig>) => {
91
+ setFilters(filters.map((f, i) => (i === index ? { ...f, ...updates } : f)));
92
+ };
93
+ const [sort, setSort] = useState<SortConfig | null>(null);
94
+ const [showFilters, setShowFilters] = useState(false);
95
+ const [showColumns, setShowColumns] = useState(false);
96
+
97
+ function flattenFields(fields: FieldConfig[]): FieldConfig[] {
98
+ const result: FieldConfig[] = [];
99
+ for (const field of fields) {
100
+ if (!field.name || field.admin?.hidden || field.name === "id") continue;
101
+ if (field.type === "tabs" && field.tabs) {
102
+ for (const tab of field.tabs) {
103
+ if (tab.fields) {
104
+ result.push(...flattenFields(tab.fields));
105
+ }
106
+ }
107
+ } else if (
108
+ (field.type === "row" || field.type === "collapsible") &&
109
+ field.fields
110
+ ) {
111
+ result.push(...flattenFields(field.fields));
112
+ } else {
113
+ result.push(field);
114
+ }
115
+ }
116
+ return result;
117
+ }
118
+
119
+ const allFields = useMemo(
120
+ () => flattenFields(collection.fields),
121
+ [collection.fields],
122
+ );
123
+
124
+ const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
125
+ if (collection.admin?.defaultColumns) {
126
+ return new Set(collection.admin.defaultColumns);
127
+ }
128
+ const defaultCols = allFields.slice(0, 4).map((f) => f.name);
129
+ return new Set(defaultCols);
130
+ });
131
+
132
+ const toggleColumn = useCallback((fieldName: string) => {
133
+ setVisibleColumns((prev) => {
134
+ const next = new Set(prev);
135
+ if (next.has(fieldName)) {
136
+ next.delete(fieldName);
137
+ } else {
138
+ next.add(fieldName);
139
+ }
140
+ return next;
141
+ });
142
+ }, []);
143
+
144
+ function resolveSortField(fieldName: string): string {
145
+ const field = allFields.find((f) => f.name === fieldName);
146
+ if (!field) return fieldName;
147
+ if (field.type === "group" && field.fields?.[0]?.name) {
148
+ return `${fieldName}.${field.fields[0].name}`;
149
+ }
150
+ return fieldName;
151
+ }
152
+
153
+ const handleSort = useCallback((fieldName: string) => {
154
+ const resolvedField = resolveSortField(fieldName);
155
+ setSort((prev) => {
156
+ if (prev && prev.field === resolvedField) {
157
+ return {
158
+ field: resolvedField,
159
+ direction: prev.direction === "asc" ? "desc" : "asc",
160
+ };
161
+ }
162
+ return { field: resolvedField, direction: "asc" };
163
+ });
164
+ }, []);
165
+
166
+ const [deleteConfirm, setDeleteConfirm] = useState<{
167
+ open: boolean;
168
+ count: number;
169
+ ids?: string[];
170
+ }>({ open: false, count: 0 });
171
+
172
+ const displayFields = useMemo(
173
+ () => allFields.filter((f) => visibleColumns.has(f.name)),
174
+ [allFields, visibleColumns],
175
+ );
176
+
177
+ const titleField =
178
+ collection.admin?.useAsTitle ||
179
+ allFields.find((f) => f.type !== "group")?.name;
180
+
181
+ function fieldContainsTitle(field: FieldConfig): boolean {
182
+ if (field.name === titleField) return true;
183
+ if (field.type === "group" && field.fields?.[0]?.name === titleField)
184
+ return true;
185
+ return false;
186
+ }
187
+
188
+ function extractFieldValue(doc: any, field: FieldConfig): any {
189
+ if (doc[field.name] !== undefined && doc[field.name] !== null) {
190
+ return doc[field.name];
191
+ }
192
+ if (field.type === "group" && typeof doc[field.name] === "object") {
193
+ if (
194
+ field.fields?.[0]?.name &&
195
+ doc[field.name][field.fields[0].name] !== undefined
196
+ ) {
197
+ return doc[field.name][field.fields[0].name];
198
+ }
199
+ const firstKey = Object.keys(doc[field.name])[0];
200
+ if (firstKey) return doc[field.name][firstKey];
201
+ }
202
+ return null;
203
+ }
204
+
205
+ const fetchDocs = useCallback(async () => {
206
+ setLoading(true);
207
+ try {
208
+ const params = new URLSearchParams({
209
+ page: page.toString(),
210
+ limit: limit.toString(),
211
+ });
212
+
213
+ if (search) params.append("search", search);
214
+ if (sort) params.append("sort", sort.field);
215
+ if (sort) params.append("order", sort.direction);
216
+ if (filters.length > 0) {
217
+ params.append("filters", JSON.stringify(filters));
218
+ }
219
+
220
+ const response = await fetch(
221
+ `/api/${collectionSlug}?${params}&t=${Date.now()}`,
222
+ );
223
+ if (!response.ok) throw new Error("Failed to load");
224
+ const result = await response.json();
225
+ setDocs(result.docs || []);
226
+ setTotalDocs(result.totalDocs || 0);
227
+ } catch (error) {
228
+ console.error("Failed to load docs:", error);
229
+ } finally {
230
+ setLoading(false);
231
+ }
232
+ }, [collectionSlug, page, limit, search, sort, filters]);
233
+
234
+ useEffect(() => {
235
+ fetchDocs();
236
+ }, [fetchDocs]);
237
+
238
+ const handleSelectAll = () => {
239
+ if (selectedIds.size === docs.length) {
240
+ setSelectedIds(new Set());
241
+ } else {
242
+ setSelectedIds(new Set(docs.map((d) => d.id)));
243
+ }
244
+ };
245
+
246
+ const handleSelectOne = (id: string) => {
247
+ const newSet = new Set(selectedIds);
248
+ if (newSet.has(id)) {
249
+ newSet.delete(id);
250
+ } else {
251
+ newSet.add(id);
252
+ }
253
+ setSelectedIds(newSet);
254
+ };
255
+
256
+ const handleBulkDelete = async () => {
257
+ setDeleteConfirm({ open: true, count: selectedIds.size });
258
+ };
259
+
260
+ const confirmBulkDelete = async () => {
261
+ try {
262
+ await Promise.all(
263
+ Array.from(selectedIds).map((id) =>
264
+ fetch(`/api/${collectionSlug}/${id}`, { method: "DELETE" }),
265
+ ),
266
+ );
267
+ setSelectedIds(new Set());
268
+ fetchDocs();
269
+ } catch (error) {
270
+ console.error("Bulk delete failed:", error);
271
+ }
272
+ setDeleteConfirm({ open: false, count: 0 });
273
+ };
274
+
275
+ const totalPages = Math.ceil(totalDocs / limit);
276
+ const hasActiveFilters = search || filters.length > 0 || sort;
277
+
278
+ return (
279
+ <div className="space-y-6">
280
+ <ConfirmModal
281
+ open={deleteConfirm.open}
282
+ onClose={() => setDeleteConfirm({ open: false, count: 0 })}
283
+ onConfirm={confirmBulkDelete}
284
+ title="Delete Documents"
285
+ message={`Are you sure you want to delete ${deleteConfirm.count} document(s)? This cannot be undone.`}
286
+ confirmLabel="Delete"
287
+ variant="danger"
288
+ />
289
+ {/* Header */}
290
+ <div className="surface-tile p-6 flex flex-col lg:flex-row lg:items-center justify-between gap-4">
291
+ <div>
292
+ <h1 className="text-2xl font-black tracking-tight text-[var(--kyro-text-primary)]">
293
+ {collection.label || collectionSlug}
294
+ </h1>
295
+ <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
296
+ {collection.admin?.description ||
297
+ `Manage your ${collection.label || collectionSlug}`}
298
+ {totalDocs > 0 && (
299
+ <span className="ml-2 font-bold">· {totalDocs} documents</span>
300
+ )}
301
+ </p>
302
+ </div>
303
+ <button
304
+ type="button"
305
+ onClick={onCreate}
306
+ className="flex items-center gap-2 px-5 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold transition-all hover:opacity-90 active:scale-95 shadow-lg"
307
+ >
308
+ <svg
309
+ className="w-4 h-4"
310
+ fill="none"
311
+ stroke="currentColor"
312
+ viewBox="0 0 24 24"
313
+ >
314
+ <path
315
+ strokeLinecap="round"
316
+ strokeLinejoin="round"
317
+ strokeWidth="2.5"
318
+ d="M12 5v14M5 12h14"
319
+ />
320
+ </svg>
321
+ Create{" "}
322
+ {collection.singularLabel || collection.label || collectionSlug}
323
+ </button>
324
+ </div>
325
+
326
+ {/* Toolbar */}
327
+ <div className="surface-tile p-4 flex flex-col lg:flex-row gap-4 items-start lg:items-center">
328
+ {/* Search */}
329
+ <div className="relative flex-1 max-w-md">
330
+ <svg
331
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)]"
332
+ fill="none"
333
+ stroke="currentColor"
334
+ viewBox="0 0 24 24"
335
+ >
336
+ <path
337
+ strokeLinecap="round"
338
+ strokeLinejoin="round"
339
+ strokeWidth="2"
340
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
341
+ />
342
+ </svg>
343
+ <input
344
+ type="text"
345
+ placeholder="Search..."
346
+ value={search}
347
+ onChange={(e) => setSearch(e.target.value)}
348
+ className="w-full pl-10 pr-4 py-2.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-xl text-sm font-medium text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
349
+ />
350
+ </div>
351
+
352
+ <div className="flex items-center gap-2 flex-wrap">
353
+ {/* Filter Toggle */}
354
+ <button
355
+ type="button"
356
+ onClick={() => setShowFilters(!showFilters)}
357
+ className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm transition-all ${
358
+ showFilters || filters.length > 0
359
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
360
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
361
+ }`}
362
+ >
363
+ <svg
364
+ className="w-4 h-4"
365
+ fill="none"
366
+ stroke="currentColor"
367
+ viewBox="0 0 24 24"
368
+ >
369
+ <path
370
+ strokeLinecap="round"
371
+ strokeLinejoin="round"
372
+ strokeWidth="2"
373
+ d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
374
+ />
375
+ </svg>
376
+ Filters
377
+ {filters.length > 0 && (
378
+ <span className="ml-1 px-1.5 py-0.5 bg-[var(--kyro-sidebar-text-active)] text-[var(--kyro-sidebar-active)] rounded-full text-xs">
379
+ {filters.length}
380
+ </span>
381
+ )}
382
+ </button>
383
+
384
+ {/* Column Toggle */}
385
+ <div className="relative">
386
+ <button
387
+ type="button"
388
+ onClick={() => setShowColumns(!showColumns)}
389
+ className="flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-sm bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
390
+ >
391
+ <svg
392
+ className="w-4 h-4"
393
+ fill="none"
394
+ stroke="currentColor"
395
+ viewBox="0 0 24 24"
396
+ >
397
+ <path
398
+ strokeLinecap="round"
399
+ strokeLinejoin="round"
400
+ strokeWidth="2"
401
+ d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
402
+ />
403
+ </svg>
404
+ Columns
405
+ </button>
406
+ {showColumns && (
407
+ <div className="absolute right-0 top-full mt-2 w-56 surface-tile border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 overflow-hidden">
408
+ <div className="p-3 border-b border-[var(--kyro-border)]">
409
+ <span className="text-xs font-bold uppercase tracking-wider text-[var(--kyro-text-secondary)]">
410
+ Toggle Columns
411
+ </span>
412
+ </div>
413
+ <div className="p-2 max-h-64 overflow-y-auto">
414
+ {allFields.map((field) => (
415
+ <label
416
+ key={field.name}
417
+ className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--kyro-surface-accent)] cursor-pointer"
418
+ >
419
+ <input
420
+ type="checkbox"
421
+ checked={visibleColumns.has(field.name)}
422
+ onChange={() => toggleColumn(field.name)}
423
+ className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
424
+ />
425
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)]">
426
+ {field.label || field.name}
427
+ </span>
428
+ </label>
429
+ ))}
430
+ </div>
431
+ </div>
432
+ )}
433
+ </div>
434
+
435
+ {/* Clear All */}
436
+ {hasActiveFilters && (
437
+ <button
438
+ type="button"
439
+ onClick={clearAll}
440
+ className="px-4 py-2 rounded-xl font-bold text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
441
+ >
442
+ Clear All
443
+ </button>
444
+ )}
445
+ </div>
446
+ </div>
447
+
448
+ {/* Filter Panel */}
449
+ {showFilters && (
450
+ <div className="surface-tile p-4 border-l-4 border-[var(--kyro-sidebar-active)]">
451
+ <div className="flex items-center justify-between mb-4">
452
+ <h3 className="font-bold text-[var(--kyro-text-primary)]">
453
+ Advanced Filters
454
+ </h3>
455
+ <button
456
+ type="button"
457
+ onClick={addFilter}
458
+ className="flex items-center gap-2 px-3 py-1.5 text-sm font-bold text-[var(--kyro-sidebar-active)] hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all"
459
+ >
460
+ <svg
461
+ className="w-4 h-4"
462
+ fill="none"
463
+ stroke="currentColor"
464
+ viewBox="0 0 24 24"
465
+ >
466
+ <path
467
+ strokeLinecap="round"
468
+ strokeLinejoin="round"
469
+ strokeWidth="2"
470
+ d="M12 5v14M5 12h14"
471
+ />
472
+ </svg>
473
+ Add Filter
474
+ </button>
475
+ </div>
476
+ <div className="space-y-3">
477
+ {filters.map((filter, index) => (
478
+ <div key={index} className="flex flex-wrap gap-2 items-center">
479
+ <select
480
+ value={filter.field}
481
+ onChange={(e) =>
482
+ updateFilter(index, { field: e.target.value })
483
+ }
484
+ className="px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
485
+ >
486
+ {allFields.map((field) => (
487
+ <option key={field.name} value={field.name}>
488
+ {field.label || field.name}
489
+ </option>
490
+ ))}
491
+ </select>
492
+ <select
493
+ value={filter.operator}
494
+ onChange={(e) =>
495
+ updateFilter(index, {
496
+ operator: e.target.value as FilterConfig["operator"],
497
+ })
498
+ }
499
+ className="px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
500
+ >
501
+ <option value="equals">Equals</option>
502
+ <option value="contains">Contains</option>
503
+ <option value="gt">Greater than</option>
504
+ <option value="lt">Less than</option>
505
+ <option value="gte">Greater or equal</option>
506
+ <option value="lte">Less or equal</option>
507
+ </select>
508
+ <input
509
+ type="text"
510
+ value={filter.value}
511
+ onChange={(e) =>
512
+ updateFilter(index, { value: e.target.value })
513
+ }
514
+ placeholder="Value..."
515
+ className="flex-1 min-w-[150px] px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
516
+ />
517
+ <button
518
+ type="button"
519
+ onClick={() => removeFilter(index)}
520
+ className="p-2 text-[var(--kyro-text-muted)] hover:text-red-500 transition-colors"
521
+ >
522
+ <svg
523
+ className="w-4 h-4"
524
+ fill="none"
525
+ stroke="currentColor"
526
+ viewBox="0 0 24 24"
527
+ >
528
+ <path
529
+ strokeLinecap="round"
530
+ strokeLinejoin="round"
531
+ strokeWidth="2"
532
+ d="M6 18L18 6M6 6l12 12"
533
+ />
534
+ </svg>
535
+ </button>
536
+ </div>
537
+ ))}
538
+ {filters.length === 0 && (
539
+ <p className="text-sm text-[var(--kyro-text-muted)]">
540
+ No filters applied. Click "Add Filter" to create one.
541
+ </p>
542
+ )}
543
+ </div>
544
+ </div>
545
+ )}
546
+
547
+ {/* Bulk Actions */}
548
+ {selectedIds.size > 0 && (
549
+ <div className="surface-tile p-4 flex items-center justify-between border-l-4 border-[var(--kyro-sidebar-active)]">
550
+ <span className="text-sm font-bold text-[var(--kyro-text-primary)]">
551
+ {selectedIds.size} selected
552
+ </span>
553
+ <div className="flex gap-2">
554
+ <button
555
+ type="button"
556
+ onClick={handleBulkDelete}
557
+ className="flex items-center gap-2 px-4 py-2 bg-red-500 text-white rounded-lg font-bold text-sm hover:bg-red-600 transition-all"
558
+ >
559
+ <svg
560
+ className="w-4 h-4"
561
+ fill="none"
562
+ stroke="currentColor"
563
+ viewBox="0 0 24 24"
564
+ >
565
+ <path
566
+ strokeLinecap="round"
567
+ strokeLinejoin="round"
568
+ strokeWidth="2"
569
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
570
+ />
571
+ </svg>
572
+ Delete Selected
573
+ </button>
574
+ <button
575
+ type="button"
576
+ onClick={() => setSelectedIds(new Set())}
577
+ className="px-4 py-2 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] font-bold text-sm transition-all"
578
+ >
579
+ Cancel
580
+ </button>
581
+ </div>
582
+ </div>
583
+ )}
584
+
585
+ {/* Data Table */}
586
+ <div className="surface-tile overflow-hidden">
587
+ {loading ? (
588
+ <div className="flex items-center justify-center py-20">
589
+ <Spinner />
590
+ </div>
591
+ ) : docs.length === 0 ? (
592
+ <div className="flex flex-col items-center justify-center py-16 px-8">
593
+ <div className="w-16 h-16 rounded-2xl bg-[var(--kyro-surface-accent)] flex items-center justify-center mb-4">
594
+ <svg
595
+ className="w-8 h-8 text-[var(--kyro-text-muted)]"
596
+ fill="none"
597
+ stroke="currentColor"
598
+ viewBox="0 0 24 24"
599
+ >
600
+ <path
601
+ strokeLinecap="round"
602
+ strokeLinejoin="round"
603
+ strokeWidth="1.5"
604
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
605
+ />
606
+ </svg>
607
+ </div>
608
+ <p className="font-bold text-[var(--kyro-text-primary)] text-base">
609
+ No documents found
610
+ </p>
611
+ <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
612
+ {hasActiveFilters
613
+ ? "Try adjusting your filters or search query."
614
+ : `Get started by creating your first ${(collection.singularLabel || collection.label || collectionSlug).toLowerCase()}.`}
615
+ </p>
616
+ {!hasActiveFilters && (
617
+ <button
618
+ type="button"
619
+ onClick={onCreate}
620
+ className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg font-bold text-sm shadow-md"
621
+ >
622
+ <svg
623
+ className="w-3.5 h-3.5"
624
+ fill="none"
625
+ stroke="currentColor"
626
+ viewBox="0 0 24 24"
627
+ >
628
+ <path
629
+ strokeLinecap="round"
630
+ strokeLinejoin="round"
631
+ strokeWidth="2.5"
632
+ d="M12 5v14M5 12h14"
633
+ />
634
+ </svg>
635
+ Create{" "}
636
+ {collection.singularLabel || collection.label || collectionSlug}
637
+ </button>
638
+ )}
639
+ </div>
640
+ ) : (
641
+ <div className="overflow-x-auto">
642
+ <table className="w-full text-left">
643
+ <thead>
644
+ <tr className="text-[var(--kyro-text-secondary)] font-bold text-[10px] uppercase tracking-[0.3em] border-b border-[var(--kyro-border)]">
645
+ <th className="px-4 py-4 w-10">
646
+ <input
647
+ type="checkbox"
648
+ checked={
649
+ selectedIds.size === docs.length && docs.length > 0
650
+ }
651
+ onChange={handleSelectAll}
652
+ className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
653
+ />
654
+ </th>
655
+ {displayFields.map((field) => (
656
+ <th
657
+ key={field.name}
658
+ className="px-4 py-4 cursor-pointer hover:text-[var(--kyro-text-primary)] transition-colors"
659
+ onClick={() => handleSort(field.name)}
660
+ >
661
+ <div className="flex items-center gap-2">
662
+ {checkTabbedValue(displayFields, field.type) ??
663
+ (field.label || field.name)}
664
+ {sort?.field === field.name && (
665
+ <svg
666
+ className={`w-3 h-3 ${sort.direction === "desc" ? "rotate-180" : ""}`}
667
+ fill="none"
668
+ stroke="currentColor"
669
+ viewBox="0 0 24 24"
670
+ >
671
+ <path
672
+ strokeLinecap="round"
673
+ strokeLinejoin="round"
674
+ strokeWidth="2"
675
+ d="M5 15l7-7 7 7"
676
+ />
677
+ </svg>
678
+ )}
679
+ </div>
680
+ </th>
681
+ ))}
682
+ {collection.timestamps && (
683
+ <th className="px-4 py-4">Created</th>
684
+ )}
685
+ <th className="px-4 py-4 text-right">Actions</th>
686
+ </tr>
687
+ </thead>
688
+ <tbody className="divide-y divide-[var(--kyro-border)]">
689
+ {docs.map((doc) => (
690
+ <tr
691
+ key={doc.slug}
692
+ className="hover:bg-[var(--kyro-surface-accent)] transition-colors cursor-pointer group"
693
+ onClick={() => onEdit(doc.id)}
694
+ >
695
+ <td
696
+ className="px-4 py-3"
697
+ onClick={(e) => e.stopPropagation()}
698
+ >
699
+ <input
700
+ type="checkbox"
701
+ checked={selectedIds.has(doc.id)}
702
+ onChange={() => handleSelectOne(doc.id)}
703
+ className="w-4 h-4 rounded border-[var(--kyro-border-strong)] text-[var(--kyro-sidebar-active)] focus:ring-[var(--kyro-sidebar-active)]"
704
+ />
705
+ </td>
706
+ {displayFields.map((field) => {
707
+ const rawValue = extractFieldValue(doc, field);
708
+ const cellValue =
709
+ field.type === "select" && rawValue
710
+ ? field.options?.find((o) => o.value === rawValue)
711
+ ?.label || rawValue
712
+ : formatCellValue(rawValue, field.type);
713
+ return (
714
+ <td
715
+ key={field.name}
716
+ className={`px-4 py-3 ${fieldContainsTitle(field) ? "font-bold text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
717
+ >
718
+ {cellValue}
719
+ </td>
720
+ );
721
+ })}
722
+ {collection.timestamps && (
723
+ <td className="px-4 py-3 text-sm text-[var(--kyro-text-secondary)]">
724
+ {doc.createdAt
725
+ ? new Date(doc.createdAt).toLocaleDateString(
726
+ "en-US",
727
+ {
728
+ month: "short",
729
+ day: "numeric",
730
+ year: "numeric",
731
+ },
732
+ )
733
+ : "—"}
734
+ </td>
735
+ )}
736
+ <td
737
+ className="px-4 py-3 text-right"
738
+ onClick={(e) => e.stopPropagation()}
739
+ >
740
+ <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
741
+ <button
742
+ type="button"
743
+ onClick={() => onEdit(doc.slug || doc.id)}
744
+ className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg text-sm font-bold text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] transition-all"
745
+ title="Edit"
746
+ >
747
+ <svg
748
+ className="w-4 h-4"
749
+ fill="none"
750
+ stroke="currentColor"
751
+ viewBox="0 0 24 24"
752
+ >
753
+ <path
754
+ strokeLinecap="round"
755
+ strokeLinejoin="round"
756
+ strokeWidth="2"
757
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
758
+ />
759
+ </svg>
760
+ </button>
761
+ <button
762
+ type="button"
763
+ onClick={() => handleDeleteSingle(doc.id)}
764
+ className="inline-flex items-center justify-center w-8 h-8 rounded-md text-[var(--kyro-text-muted)] hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 transition-colors"
765
+ title="Delete"
766
+ >
767
+ <svg
768
+ className="w-4 h-4"
769
+ fill="none"
770
+ stroke="currentColor"
771
+ viewBox="0 0 24 24"
772
+ >
773
+ <path
774
+ strokeLinecap="round"
775
+ strokeLinejoin="round"
776
+ strokeWidth="2"
777
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
778
+ />
779
+ </svg>
780
+ </button>
781
+ </div>
782
+ </td>
783
+ </tr>
784
+ ))}
785
+ </tbody>
786
+ </table>
787
+ </div>
788
+ )}
789
+ </div>
790
+
791
+ {/* Pagination */}
792
+ {totalDocs > limit && (
793
+ <div className="flex flex-col lg:flex-row items-center justify-between gap-4 px-2">
794
+ <div className="flex items-center gap-4">
795
+ <span className="text-sm text-[var(--kyro-text-secondary)] font-medium">
796
+ Showing{" "}
797
+ <span className="text-[var(--kyro-text-primary)] font-bold">
798
+ {(page - 1) * limit + 1}
799
+ </span>{" "}
800
+ to{" "}
801
+ <span className="text-[var(--kyro-text-primary)] font-bold">
802
+ {Math.min(page * limit, totalDocs)}
803
+ </span>{" "}
804
+ of{" "}
805
+ <span className="text-[var(--kyro-text-primary)] font-bold">
806
+ {totalDocs}
807
+ </span>
808
+ </span>
809
+ <select
810
+ value={limit}
811
+ onChange={(e) => {
812
+ setLimit(Number(e.target.value));
813
+ setPage(1);
814
+ }}
815
+ className="px-2 py-1 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm font-medium text-[var(--kyro-text-primary)]"
816
+ >
817
+ <option value={10}>10 / page</option>
818
+ <option value={25}>25 / page</option>
819
+ <option value={50}>50 / page</option>
820
+ <option value={100}>100 / page</option>
821
+ </select>
822
+ </div>
823
+ <div className="flex gap-2">
824
+ {page > 1 && (
825
+ <button
826
+ type="button"
827
+ onClick={() => setPage(page - 1)}
828
+ className="px-4 py-2 border border-[var(--kyro-border)] rounded-lg text-sm font-bold text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
829
+ >
830
+ ← Previous
831
+ </button>
832
+ )}
833
+ {page < totalPages && (
834
+ <button
835
+ type="button"
836
+ onClick={() => setPage(page + 1)}
837
+ className="px-4 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg text-sm font-bold hover:opacity-90 transition-all"
838
+ >
839
+ Next →
840
+ </button>
841
+ )}
842
+ </div>
843
+ </div>
844
+ )}
845
+ </div>
846
+ );
847
+
848
+ async function handleDeleteSingle(id: string) {
849
+ setDeleteConfirm({ open: true, count: 1, ids: [id] });
850
+ }
851
+
852
+ const confirmDelete = async () => {
853
+ try {
854
+ for (const id of deleteConfirm.ids || []) {
855
+ await fetch(`/api/${collectionSlug}/${id}`, { method: "DELETE" });
856
+ }
857
+ fetchDocs();
858
+ } catch (error) {
859
+ console.error("Delete failed:", error);
860
+ }
861
+ setDeleteConfirm({ open: false, count: 0 });
862
+ };
863
+ }
864
+
865
+ function formatCellValue(value: any, type?: string): string {
866
+ if (value === null || value === undefined) return "—";
867
+ if (typeof value === "boolean") return value ? "Yes" : "No";
868
+ if (type === "number" || type === "price") return String(value);
869
+ if (type === "date" || type === "datetime") {
870
+ return new Date(value).toLocaleDateString("en-US", {
871
+ month: "short",
872
+ day: "numeric",
873
+ year: "numeric",
874
+ });
875
+ }
876
+
877
+ if (typeof value === "object") {
878
+ if (value.title) return value.title;
879
+ if (value.name) return value.name;
880
+ if (value.email) return value.email;
881
+ return JSON.stringify(value).slice(0, 50);
882
+ }
883
+ return String(value).slice(0, 60);
884
+ }
885
+
886
+ function checkTabbedValue(data: any[], type: string): string | undefined {
887
+ if (type !== "tabs") return;
888
+ const label = data[0]?.tabs[0]?.fields[0]?.label;
889
+ return label;
890
+ }