@kyro-cms/admin 0.1.5 → 0.1.7

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