@kyro-cms/admin 0.3.2 → 0.3.5

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