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