@kyro-cms/admin 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
@@ -1,144 +1,969 @@
1
1
  import { useState, useRef, useEffect } from "react";
2
- import type { CollectionConfig, Field, Block } from "@kyro-cms/core";
2
+ import type {
3
+ CollectionConfig,
4
+ GlobalConfig,
5
+ Field,
6
+ Block,
7
+ } from "@kyro-cms/core/client";
8
+ import { UploadField } from "./fields/UploadField";
9
+ import { CodeField } from "./fields";
10
+ import NumberField from "./fields/NumberField";
11
+ import CheckboxField from "./fields/CheckboxField";
12
+ import SelectField from "./fields/SelectField";
13
+ import DateField from "./fields/DateField";
14
+ import { MarkdownField } from "./fields/MarkdownField";
15
+ import { globals, collections } from "@/lib/config";
16
+ import { slugifyText } from "@/lib/slugify";
17
+
18
+ import { BlocksField } from "./fields/BlocksField";
19
+ import PortableTextField from "./fields/PortableTextField";
20
+ import { ConfirmModal, UIModal } from "./Modal";
3
21
 
4
22
  interface AutoFormProps {
5
- config: CollectionConfig;
23
+ config: CollectionConfig | GlobalConfig;
6
24
  data?: Record<string, any>;
7
25
  errors?: Record<string, string>;
8
26
  onChange?: (data: Record<string, any>) => void;
9
27
  disabled?: boolean;
10
28
  collectionSlug?: string;
29
+ globalSlug?: string;
30
+ documentName?: string;
31
+ layout?: "split" | "single";
32
+ onActionSuccess?: (message: string) => void;
33
+ onActionError?: (message: string) => void;
34
+ documentStatus?: "draft" | "published";
35
+ justSaved?: boolean;
11
36
  }
12
37
 
13
38
  export function AutoForm({
14
- config,
15
- data = {},
39
+ config: propConfig,
40
+ data: initialData = {},
16
41
  errors = {},
17
42
  onChange,
18
- disabled,
43
+ disabled: propDisabled,
44
+ collectionSlug,
45
+ globalSlug,
46
+ documentName,
47
+ layout = "split",
48
+ onActionSuccess,
49
+ onActionError,
50
+ documentStatus,
51
+ justSaved,
19
52
  }: AutoFormProps) {
53
+ // Resolve the "live" config to preserve functions (admin.condition) lost during prop serialization
54
+ const activeConfig = globalSlug
55
+ ? globals[globalSlug]
56
+ : collectionSlug
57
+ ? collections[collectionSlug]
58
+ : propConfig;
59
+ const config = activeConfig || propConfig;
60
+
61
+ // Helper to extract default values from config recursively
62
+ function getDefaults(fields: any[], prefix = ""): Record<string, any> {
63
+ const defaults: Record<string, any> = {};
64
+ for (const field of fields || []) {
65
+ if (field.defaultValue !== undefined) {
66
+ const key = prefix + field.name;
67
+ defaults[key] = field.defaultValue;
68
+ // Also set nested defaults for groups
69
+ if (field.type === "group" && field.fields) {
70
+ for (const subField of field.fields) {
71
+ if (subField.defaultValue !== undefined) {
72
+ defaults[prefix + field.name + "." + subField.name] =
73
+ subField.defaultValue;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ if (field.fields && Array.isArray(field.fields)) {
79
+ Object.assign(defaults, getDefaults(field.fields, field.name + "."));
80
+ }
81
+ if (field.tabs) {
82
+ for (const tab of field.tabs) {
83
+ if (tab.fields) {
84
+ Object.assign(defaults, getDefaults(tab.fields, prefix));
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return defaults;
90
+ }
91
+
92
+ // Helper to flatten nested object with dot notation keys
93
+ function flattenObject(
94
+ obj: Record<string, any>,
95
+ prefix = "",
96
+ ): Record<string, any> {
97
+ const result: Record<string, any> = {};
98
+ for (const key in obj) {
99
+ const newKey = prefix ? `${prefix}.${key}` : key;
100
+ const val = obj[key];
101
+ if (
102
+ val !== null &&
103
+ typeof val === "object" &&
104
+ !Array.isArray(val) &&
105
+ // Only recurse into plain objects, not Dates, Maps, or other class instances
106
+ (val.constructor === Object || !val.constructor)
107
+ ) {
108
+ Object.assign(result, flattenObject(val, newKey));
109
+ } else {
110
+ result[newKey] = val;
111
+ }
112
+ }
113
+ return result;
114
+ }
115
+
116
+ // Helper to unflatten dot notation keys back to nested object
117
+ function unflattenObject(flat: Record<string, any>): Record<string, any> {
118
+ const result: Record<string, any> = {};
119
+ for (const key in flat) {
120
+ const parts = key.split(".");
121
+ let current = result;
122
+ for (let i = 0; i < parts.length - 1; i++) {
123
+ if (!current[parts[i]]) {
124
+ current[parts[i]] = {};
125
+ }
126
+ current = current[parts[i]];
127
+ }
128
+ current[parts[parts.length - 1]] = flat[key];
129
+ }
130
+ return result;
131
+ }
132
+
133
+ // Merge initial data with defaults from config
134
+ const [formData, setFormData] = useState<Record<string, any>>({});
135
+
136
+ useEffect(() => {
137
+ try {
138
+ const configDefaults = config ? getDefaults(config.fields) : {};
139
+ const flatInitialData = flattenObject(initialData || {});
140
+ const mergedFlatData = { ...configDefaults, ...flatInitialData };
141
+ const mergedInitialData = unflattenObject(mergedFlatData);
142
+ setFormData(mergedInitialData);
143
+ } catch (e) {
144
+ console.error("Critical error in AutoForm data initialization:", e);
145
+ // Fallback to raw initialData if flattening fails
146
+ setFormData(initialData || {});
147
+ }
148
+ }, [initialData, config]);
149
+ const [activeTab, setActiveTab] = useState(0);
150
+ const [isSlugLocked, setIsSlugLocked] = useState(true);
151
+ const [view, setView] = useState<"edit" | "version" | "api">("edit");
152
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
153
+ const [versions, setVersions] = useState<any[]>([]);
154
+ const [loadingVersions, setLoadingVersions] = useState(false);
155
+ const [showPreview, setShowPreview] = useState(false);
156
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
157
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
158
+ const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>(
159
+ {},
160
+ );
161
+ const menuRef = useRef<HTMLDivElement>(null);
162
+ const [compareMode, setCompareMode] = useState(false);
163
+ const [compareSelected, setCompareSelected] = useState<string[]>([]);
164
+ const [compareDiffs, setCompareDiffs] = useState<any[]>([]);
165
+ const [loadingDiffs, setLoadingDiffs] = useState(false);
166
+ const [confirmModal, setConfirmModal] = useState<{
167
+ open: boolean;
168
+ title: string;
169
+ message: string;
170
+ onConfirm: () => void;
171
+ danger?: boolean;
172
+ }>({ open: false, title: "", message: "", onConfirm: () => {} });
173
+ const [alertModal, setAlertModal] = useState<{
174
+ open: boolean;
175
+ title: string;
176
+ message: string;
177
+ }>({ open: false, title: "", message: "" });
178
+ const [lastSavedData, setLastSavedData] = useState<Record<string, any>>({});
179
+ const [isAutoSaving, setIsAutoSaving] = useState(false);
180
+ const [autoSaveStatus, setAutoSaveStatus] = useState<
181
+ "idle" | "saving" | "saved" | "error"
182
+ >("idle");
183
+ const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
184
+ const lastAutoSaveTimeRef = useRef<number>(0);
185
+ const autoSaveSkipRef = useRef<boolean>(false);
186
+
187
+ const disabled = propDisabled;
188
+
189
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
190
+
191
+ useEffect(() => {
192
+ const handleToggle = () => {
193
+ setSidebarCollapsed((prev) => !prev);
194
+ };
195
+ window.addEventListener("toggle-sidebar", handleToggle);
196
+ return () => window.removeEventListener("toggle-sidebar", handleToggle);
197
+ }, []);
198
+
199
+ // Track unsaved changes (compare against last saved state)
200
+ useEffect(() => {
201
+ const isDifferent =
202
+ JSON.stringify(formData) !== JSON.stringify(lastSavedData);
203
+ setHasUnsavedChanges(isDifferent);
204
+ }, [formData, lastSavedData]);
205
+
206
+ // Auto-generate slug from configured source field if locked
207
+ useEffect(() => {
208
+ const slugField = config.fields.find(
209
+ (f: any) => f.name === "slug" && f.admin?.autoGenerate,
210
+ );
211
+ if (!slugField?.admin?.autoGenerate) return;
212
+ const sourceField: string = slugField.admin.autoGenerate;
213
+ if (isSlugLocked && formData[sourceField]) {
214
+ const newSlug = slugifyText(formData[sourceField]);
215
+ if (newSlug !== formData.slug) {
216
+ setFormData((prev) => ({ ...prev, slug: newSlug }));
217
+ }
218
+ }
219
+ }, [
220
+ formData.title,
221
+ formData.name,
222
+ formData.label,
223
+ isSlugLocked,
224
+ config.fields,
225
+ ]);
226
+
227
+ // Sync prop changes to local state
228
+ useEffect(() => {
229
+ if (initialData && Object.keys(initialData).length > 0) {
230
+ setFormData(initialData);
231
+ setLastSavedData(initialData);
232
+ }
233
+ }, [initialData]);
234
+
235
+ // Auto-save with Strategy 3: 1s debounce, lastSavedData comparison, 15s hard throttle
236
+ useEffect(() => {
237
+ if (!formData.id || sidebarCollapsed) return;
238
+
239
+ if (autoSaveTimerRef.current) {
240
+ clearTimeout(autoSaveTimerRef.current);
241
+ }
242
+
243
+ const now = Date.now();
244
+ const timeSinceLastSave = now - lastAutoSaveTimeRef.current;
245
+ const hasChanges =
246
+ JSON.stringify(formData) !== JSON.stringify(lastSavedData);
247
+
248
+ if (!hasChanges) {
249
+ setAutoSaveStatus("idle");
250
+ return;
251
+ }
252
+
253
+ if (timeSinceLastSave < 15000 && lastAutoSaveTimeRef.current > 0) {
254
+ const remainingTime = Math.max(1000, 15000 - timeSinceLastSave);
255
+ autoSaveTimerRef.current = setTimeout(async () => {
256
+ await performAutoSave();
257
+ }, remainingTime);
258
+ } else {
259
+ autoSaveTimerRef.current = setTimeout(async () => {
260
+ await performAutoSave();
261
+ }, 1000);
262
+ }
263
+
264
+ return () => {
265
+ if (autoSaveTimerRef.current) {
266
+ clearTimeout(autoSaveTimerRef.current);
267
+ }
268
+ };
269
+ }, [formData]);
270
+
271
+ const performAutoSave = async () => {
272
+ if (autoSaveSkipRef.current) return;
273
+ if (JSON.stringify(formData) === JSON.stringify(lastSavedData)) return;
274
+
275
+ setIsAutoSaving(true);
276
+ setAutoSaveStatus("saving");
277
+
278
+ try {
279
+ const { id, createdAt, updatedAt, ...rest } = formData;
280
+ const saveData = {
281
+ ...rest,
282
+ _changeDescription: "Auto-saved",
283
+ status: formData.status === "published" ? "draft" : formData.status,
284
+ };
285
+
286
+ const response = await fetch(`/api/${collectionSlug}/${formData.id}`, {
287
+ method: "PATCH",
288
+ credentials: "include",
289
+ headers: { "Content-Type": "application/json" },
290
+ body: JSON.stringify(saveData),
291
+ });
292
+
293
+ if (response.ok) {
294
+ const result = await response.json();
295
+ setLastSavedData(result.data || formData);
296
+ lastAutoSaveTimeRef.current = Date.now();
297
+ setAutoSaveStatus("saved");
298
+ fetchVersions();
299
+ setTimeout(() => setAutoSaveStatus("idle"), 2000);
300
+ } else {
301
+ setAutoSaveStatus("error");
302
+ setTimeout(() => setAutoSaveStatus("idle"), 3000);
303
+ }
304
+ } catch (err) {
305
+ console.error("Auto-save failed:", err);
306
+ setAutoSaveStatus("error");
307
+ setTimeout(() => setAutoSaveStatus("idle"), 3000);
308
+ } finally {
309
+ setIsAutoSaving(false);
310
+ }
311
+ };
312
+
313
+ // Sync to hidden input for Astro form submission
314
+ useEffect(() => {
315
+ const hiddenInput = document.getElementById(
316
+ "form-data",
317
+ ) as HTMLInputElement;
318
+ if (hiddenInput) {
319
+ hiddenInput.value = JSON.stringify(formData);
320
+ }
321
+ onChange?.(formData);
322
+ }, [formData, onChange]);
323
+
324
+ useEffect(() => {
325
+ if (formData.id) fetchVersions();
326
+ }, [formData.id]);
327
+
328
+ const fetchVersions = async () => {
329
+ setLoadingVersions(true);
330
+ try {
331
+ const resp = await fetch(
332
+ `/api/${collectionSlug}/${formData.id}/versions`,
333
+ );
334
+ const data = await resp.json();
335
+ setVersions(data.docs || []);
336
+ } catch (e) {
337
+ console.error("Failed to fetch versions:", e);
338
+ } finally {
339
+ setLoadingVersions(false);
340
+ }
341
+ };
342
+
343
+ const handleRestoreVersion = async (versionId: string) => {
344
+ if (
345
+ !confirm(
346
+ "Are you sure you want to restore this version? This will overwrite your current changes.",
347
+ )
348
+ )
349
+ return;
350
+ try {
351
+ const resp = await fetch(
352
+ `/api/${collectionSlug}/${formData.id}/versions`,
353
+ {
354
+ method: "POST",
355
+ headers: { "Content-Type": "application/json" },
356
+ body: JSON.stringify({ versionId, action: "restore" }),
357
+ },
358
+ );
359
+ const result = await resp.json();
360
+ if (result.data) {
361
+ setFormData(result.data);
362
+ setView("edit");
363
+ fetchVersions();
364
+ }
365
+ } catch (e) {
366
+ console.error("Restore failed:", e);
367
+ }
368
+ };
369
+
370
+ const handleCompareVersions = async () => {
371
+ if (compareSelected.length !== 2) return;
372
+ setLoadingDiffs(true);
373
+ try {
374
+ const resp = await fetch(
375
+ `/api/${collectionSlug}/${formData.id}/versions?compareA=${compareSelected[0]}&compareB=${compareSelected[1]}`,
376
+ );
377
+ const data = await resp.json();
378
+ setCompareDiffs(data.diffs || []);
379
+ } catch (e) {
380
+ console.error("Compare failed:", e);
381
+ setCompareDiffs([]);
382
+ } finally {
383
+ setLoadingDiffs(false);
384
+ }
385
+ };
386
+
387
+ const toggleCompareSelection = (versionId: string) => {
388
+ setCompareSelected((prev) => {
389
+ if (prev.includes(versionId)) {
390
+ return prev.filter((id) => id !== versionId);
391
+ }
392
+ if (prev.length >= 2) {
393
+ return [prev[1], versionId];
394
+ }
395
+ return [...prev, versionId];
396
+ });
397
+ };
398
+
399
+ useEffect(() => {
400
+ const handleShortcuts = (e: KeyboardEvent) => {
401
+ // Cmd/Ctrl + S = Publish
402
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
403
+ e.preventDefault();
404
+ (document.getElementById("btn-save") as any)?.click();
405
+ }
406
+ // Cmd/Ctrl + P = Toggle Preview
407
+ if ((e.metaKey || e.ctrlKey) && e.key === "p") {
408
+ e.preventDefault();
409
+ setShowPreview((prev) => !prev);
410
+ }
411
+ // Keys 1, 2, 3 = Tab Switching
412
+ if (
413
+ document.activeElement?.tagName !== "INPUT" &&
414
+ document.activeElement?.tagName !== "TEXTAREA"
415
+ ) {
416
+ if (e.key === "1") setView("edit");
417
+ if (e.key === "2") setView("version");
418
+ if (e.key === "3") setView("api");
419
+ }
420
+ };
421
+ window.addEventListener("keydown", handleShortcuts);
422
+ return () => window.removeEventListener("keydown", handleShortcuts);
423
+ }, []);
424
+
425
+ useEffect(() => {
426
+ const handleClickOutside = (e: MouseEvent) => {
427
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
428
+ setIsMenuOpen(false);
429
+ }
430
+ };
431
+ if (isMenuOpen) {
432
+ document.addEventListener("mousedown", handleClickOutside);
433
+ return () =>
434
+ document.removeEventListener("mousedown", handleClickOutside);
435
+ }
436
+ }, [isMenuOpen]);
437
+
438
+ const handleCreateNew = () => {
439
+ if (hasUnsavedChanges) {
440
+ setConfirmModal({
441
+ open: true,
442
+ title: "Unsaved Changes",
443
+ message: "You have unsaved changes. Save before creating new?",
444
+ onConfirm: async () => {
445
+ (document.getElementById("btn-save") as any)?.click();
446
+ await new Promise((r) => setTimeout(r, 1000));
447
+ window.location.href = `/${collectionSlug}/new`;
448
+ },
449
+ });
450
+ } else {
451
+ window.location.href = `/${collectionSlug}/new`;
452
+ }
453
+ };
454
+
455
+ const handleDuplicate = () => {
456
+ setConfirmModal({
457
+ open: true,
458
+ title: "Duplicate Document",
459
+ message: "Create a duplicate of this document?",
460
+ onConfirm: async () => {
461
+ const { id, createdAt, updatedAt, status, ...rest } = formData;
462
+ const duplicateData = {
463
+ ...rest,
464
+ title: `${rest.title || rest.name || "Untitled"} (Copy)`,
465
+ };
466
+ try {
467
+ const response = await fetch(`/api/${collectionSlug}`, {
468
+ method: "POST",
469
+ credentials: "include",
470
+ headers: { "Content-Type": "application/json" },
471
+ body: JSON.stringify(duplicateData),
472
+ });
473
+ if (response.ok) {
474
+ const result = await response.json();
475
+ window.location.href = `/${collectionSlug}/${result.data.id}`;
476
+ } else {
477
+ const error = await response.json();
478
+ setAlertModal({
479
+ open: true,
480
+ title: "Error",
481
+ message: error.error || "Failed to duplicate document",
482
+ });
483
+ }
484
+ } catch (err) {
485
+ setAlertModal({
486
+ open: true,
487
+ title: "Error",
488
+ message: "Failed to duplicate document",
489
+ });
490
+ }
491
+ },
492
+ });
493
+ };
494
+
495
+ const handleDelete = () => {
496
+ setConfirmModal({
497
+ open: true,
498
+ title: "Delete Document",
499
+ message: "Delete this document? This cannot be undone.",
500
+ danger: true,
501
+ onConfirm: () => {
502
+ setConfirmModal({
503
+ open: true,
504
+ title: "Confirm Deletion",
505
+ message: "Are you absolutely sure?",
506
+ danger: true,
507
+ onConfirm: async () => {
508
+ try {
509
+ const response = await fetch(
510
+ `/api/${collectionSlug}/${formData.id}`,
511
+ {
512
+ method: "DELETE",
513
+ credentials: "include",
514
+ },
515
+ );
516
+ if (response.ok) {
517
+ window.location.href = `/${collectionSlug}`;
518
+ } else {
519
+ const error = await response.json();
520
+ setAlertModal({
521
+ open: true,
522
+ title: "Error",
523
+ message: error.error || "Failed to delete document",
524
+ });
525
+ }
526
+ } catch (err) {
527
+ setAlertModal({
528
+ open: true,
529
+ title: "Error",
530
+ message: "Failed to delete document",
531
+ });
532
+ }
533
+ },
534
+ });
535
+ },
536
+ });
537
+ };
538
+
539
+ const handleUnpublish = () => {
540
+ setConfirmModal({
541
+ open: true,
542
+ title: "Unpublish Document",
543
+ message: "Unpublish this document?",
544
+ onConfirm: async () => {
545
+ try {
546
+ const response = await fetch(
547
+ `/api/${collectionSlug}/${formData.id}/unpublish`,
548
+ {
549
+ method: "POST",
550
+ credentials: "include",
551
+ },
552
+ );
553
+ if (response.ok) {
554
+ onActionSuccess?.("Document unpublished successfully");
555
+ location.reload();
556
+ } else {
557
+ const error = await response.json();
558
+ setAlertModal({
559
+ open: true,
560
+ title: "Error",
561
+ message: error.error || "Failed to unpublish",
562
+ });
563
+ }
564
+ } catch (err) {
565
+ setAlertModal({
566
+ open: true,
567
+ title: "Error",
568
+ message: "Failed to unpublish",
569
+ });
570
+ }
571
+ },
572
+ });
573
+ };
574
+
20
575
  const handleFieldChange = (fieldName: string, value: any) => {
21
- onChange?.({
22
- ...data,
576
+ setFormData((prev) => ({
577
+ ...prev,
23
578
  [fieldName]: value,
24
- });
579
+ }));
25
580
  };
26
581
 
27
- const renderField = (field: Field, parentData?: Record<string, any>) => {
582
+ const renderField = (
583
+ field: Field,
584
+ parentData?: Record<string, any>,
585
+ onParentChange?: (val: any) => void,
586
+ ): React.ReactNode => {
28
587
  if (field.admin?.hidden) return null;
29
588
 
30
- const value =
31
- parentData !== undefined ? parentData[field.name!] : data[field.name!];
589
+ const currentData = parentData !== undefined ? parentData : formData;
590
+
591
+ // Evaluate display condition if present
592
+ // For conditional fields, pass formData as the root context (first arg)
593
+ // and currentData as the sibling context (second arg)
594
+ if (field.admin?.condition && typeof field.admin.condition === "function") {
595
+ try {
596
+ const shouldShow = field.admin.condition(formData, currentData);
597
+ if (!shouldShow) {
598
+ return null;
599
+ }
600
+ } catch (e) {
601
+ console.warn(`Condition error for field ${field.name}:`, e);
602
+ // Show the field if there's an error evaluating the condition
603
+ }
604
+ }
605
+
606
+ const value = currentData[field.name!];
32
607
  const error = errors[field.name!];
33
608
 
609
+ const onFieldChange = (val: any) => {
610
+ if (onParentChange) {
611
+ onParentChange({ ...currentData, [field.name!]: val });
612
+ } else {
613
+ handleFieldChange(field.name!, val);
614
+ }
615
+ };
616
+
34
617
  if (field.type === "row" && "fields" in field) {
35
618
  return (
36
619
  <div
37
620
  key={field.name || `row-${Math.random()}`}
38
- className="kyro-form-row"
621
+ className="kyro-form-row flex gap-6 items-end"
39
622
  >
40
- {(field as any).fields.map((f: Field) => renderField(f, parentData))}
41
- </div>
42
- );
43
- }
623
+ {(field as any).fields.map((f: Field) => {
624
+ const fAdmin = f.admin;
625
+ const actionUrl = fAdmin?.action;
44
626
 
45
- if (field.type === "collapsible" && "fields" in field) {
46
- return (
47
- <details
48
- key={field.name || `collapsible-${Math.random()}`}
49
- className="kyro-form-collapsible"
50
- >
51
- <summary className="kyro-form-collapsible-header">
52
- <svg
53
- width="16"
54
- height="16"
55
- viewBox="0 0 24 24"
56
- fill="none"
57
- stroke="currentColor"
58
- strokeWidth="2"
59
- >
60
- <path d="M9 18l6-6-6-6" />
61
- </svg>
62
- {(field as any).label}
63
- </summary>
64
- <div className="kyro-form-collapsible-content">
65
- {(field as any).fields.map((f: Field) =>
66
- renderField(f, parentData),
67
- )}
68
- </div>
69
- </details>
70
- );
71
- }
627
+ if (f.type === "button" && actionUrl) {
628
+ const siblingEmailField = (field as any).fields?.find(
629
+ (ff: Field) => ff.type === "email",
630
+ );
631
+ return (
632
+ <div key={f.name} className="flex-shrink-0">
633
+ <button
634
+ type="button"
635
+ disabled={disabled}
636
+ onClick={async () => {
637
+ const rowName = field.name;
638
+ const emailFieldName = siblingEmailField?.name;
639
+ let emailValue = formData[emailFieldName];
640
+ if (!emailValue && rowName) {
641
+ emailValue = formData[rowName]?.[emailFieldName];
642
+ }
643
+ if (!emailValue) return;
72
644
 
73
- if (field.type === "tabs" && "tabs" in field) {
74
- return (
75
- <div
76
- key={field.name || `tabs-${Math.random()}`}
77
- className="kyro-form-tabs"
78
- >
79
- {(field as any).tabs.map((tab: any, index: number) => (
80
- <div key={index} className="kyro-form-tab">
81
- <h3 className="kyro-form-tab-title">{tab.label}</h3>
82
- <div className="kyro-form-tab-content">
83
- {tab.fields.map((f: Field) => renderField(f, parentData))}
645
+ setLoadingFields((prev) => ({
646
+ ...prev,
647
+ [f.name!]: true,
648
+ }));
649
+ try {
650
+ const response = await fetch(actionUrl, {
651
+ method: fAdmin.method || "POST",
652
+ headers: { "Content-Type": "application/json" },
653
+ body: JSON.stringify({ email: emailValue }),
654
+ });
655
+ let result;
656
+ try {
657
+ result = await response.json();
658
+ } catch {
659
+ result = {};
660
+ }
661
+ if (response.ok && result.success) {
662
+ onActionSuccess?.(
663
+ result.message || "Action completed successfully",
664
+ );
665
+ } else {
666
+ const errorMsg =
667
+ result.error ||
668
+ `Request failed (${response.status})`;
669
+ onActionError?.(errorMsg);
670
+ }
671
+ } catch (err: any) {
672
+ onActionError?.(
673
+ err.message || "Error connecting to server",
674
+ );
675
+ } finally {
676
+ setLoadingFields((prev) => ({
677
+ ...prev,
678
+ [f.name!]: false,
679
+ }));
680
+ }
681
+ }}
682
+ //@ts-ignore
683
+ disabled={loadingFields[f.name!] || disabled}
684
+ className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
685
+ >
686
+ {loadingFields[f.name!] ? "Sending..." : f.label || "Click"}
687
+ </button>
688
+ </div>
689
+ );
690
+ }
691
+
692
+ return (
693
+ <div
694
+ key={f.name}
695
+ className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
696
+ style={
697
+ fAdmin?.width ? { width: fAdmin.width, flex: "none" } : {}
698
+ }
699
+ >
700
+ {renderField(f, parentData, onParentChange)}
84
701
  </div>
85
- </div>
86
- ))}
702
+ );
703
+ })}
87
704
  </div>
88
705
  );
89
706
  }
90
707
 
91
708
  switch (field.type) {
92
- case "text":
93
- case "email":
709
+ case "tabs": {
710
+ const fieldTabs = (field as any).tabs;
711
+ const currentTab = fieldTabs[activeTab] || fieldTabs[0];
712
+
94
713
  return (
95
- <div key={field.name} className="kyro-form-field">
96
- <label className="kyro-form-label">
97
- {field.label || field.name}
98
- {field.required && (
99
- <span className="kyro-form-label-required">*</span>
714
+ <div
715
+ key={field.name || `tabs-${Math.random()}`}
716
+ className="space-y-8"
717
+ >
718
+ <div className="flex items-center gap-1 border-b border-[var(--kyro-border)] mb-6">
719
+ {fieldTabs.map((tab: any, index: number) => (
720
+ <button
721
+ key={index}
722
+ type="button"
723
+ className={`px-6 py-3 text-sm font-bold transition-all border-b-2 -mb-[1px] ${
724
+ activeTab === index
725
+ ? "border-[var(--kyro-text-primary)] text-[var(--kyro-text-primary)]"
726
+ : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
727
+ }`}
728
+ onClick={() => setActiveTab(index)}
729
+ >
730
+ {tab.label}
731
+ </button>
732
+ ))}
733
+ </div>
734
+ <div className="space-y-6">
735
+ {currentTab?.fields.map((f: Field) =>
736
+ renderField(f, parentData, onParentChange),
100
737
  )}
101
- </label>
102
- <input
103
- type={
104
- field.type === "email"
105
- ? "email"
106
- : (field as any).variant === "url"
107
- ? "url"
108
- : "text"
109
- }
110
- className={`kyro-form-input ${error ? "kyro-form-input-error" : ""}`}
111
- value={value || ""}
112
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
113
- disabled={disabled}
114
- placeholder={`Enter ${field.label || field.name}`}
115
- minLength={(field as any).minLength}
116
- maxLength={(field as any).maxLength}
117
- />
118
- {field.admin?.description && !error && (
119
- <p className="kyro-form-help">{field.admin.description}</p>
738
+ </div>
739
+
740
+ {currentTab?.label === "SEO" && (
741
+ <div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
742
+ <h4 className="text-xs font-bold text-[var(--kyro-text-secondary)] uppercase tracking-[0.2em] mb-6 opacity-50">
743
+ Live Google Preview
744
+ </h4>
745
+ <SeoPreview
746
+ title={formData.metaTitle || formData.title || "Untitled"}
747
+ description={
748
+ formData.metaDescription || "Please enter a description..."
749
+ }
750
+ slug={formData.slug || "your-slug"}
751
+ />
752
+ </div>
120
753
  )}
121
- {error && <p className="kyro-form-error">{error}</p>}
122
754
  </div>
123
755
  );
756
+ }
757
+ case "text":
758
+ case "email":
759
+ const textValue = currentData[field.name!];
760
+ const isKeyHidden = String(textValue).startsWith("••");
124
761
 
125
- case "password":
126
762
  return (
127
763
  <div key={field.name} className="kyro-form-field">
128
- <label className="kyro-form-label">
129
- {field.label || field.name}
130
- {field.required && (
131
- <span className="kyro-form-label-required">*</span>
764
+ <label className="kyro-form-label flex items-center justify-between">
765
+ <div className="flex items-center gap-2">
766
+ {field.label || field.name}
767
+ {field.required && (
768
+ <span className="kyro-form-label-required">*</span>
769
+ )}
770
+ </div>
771
+ {(field.admin?.autoGenerate || field.admin?.readOnly) && (
772
+ <button
773
+ type="button"
774
+ onClick={async (e) => {
775
+ e.preventDefault();
776
+ e.stopPropagation();
777
+
778
+ if (field.admin?.autoGenerate === "key") {
779
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
780
+ let suffix = "";
781
+ for (let i = 0; i < 32; i++) {
782
+ suffix +=
783
+ chars[Math.floor(Math.random() * chars.length)];
784
+ }
785
+ onFieldChange(`kyro_${suffix}`);
786
+ } else if (field.admin?.autoGenerate) {
787
+ onFieldChange(
788
+ slugifyText(
789
+ formData[field.admin!.autoGenerate as string] || "",
790
+ ),
791
+ );
792
+ } else if (
793
+ field.admin?.readOnly &&
794
+ textValue &&
795
+ !isKeyHidden
796
+ ) {
797
+ await navigator.clipboard.writeText(String(textValue));
798
+ const actualKey = textValue;
799
+ onFieldChange(actualKey + "__COPIED__");
800
+ setTimeout(
801
+ () => onFieldChange("••••••••••••••••••••••••••••••"),
802
+ 100,
803
+ );
804
+ }
805
+ }}
806
+ className="p-1.5 rounded-lg text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
807
+ title={
808
+ field.admin?.autoGenerate === "key"
809
+ ? "Generate new key"
810
+ : field.admin?.autoGenerate
811
+ ? `Generate from ${field.admin.autoGenerate}`
812
+ : "Copy to clipboard"
813
+ }
814
+ >
815
+ {field.admin?.autoGenerate === "key" ? (
816
+ <svg
817
+ width="14"
818
+ height="14"
819
+ viewBox="0 0 24 24"
820
+ fill="none"
821
+ stroke="currentColor"
822
+ strokeWidth="2"
823
+ >
824
+ <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
825
+ </svg>
826
+ ) : field.admin?.autoGenerate ? (
827
+ <svg
828
+ width="14"
829
+ height="14"
830
+ viewBox="0 0 24 24"
831
+ fill="none"
832
+ stroke="currentColor"
833
+ strokeWidth="2"
834
+ >
835
+ <path d="M12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
836
+ </svg>
837
+ ) : (
838
+ <svg
839
+ width="14"
840
+ height="14"
841
+ viewBox="0 0 24 24"
842
+ fill="none"
843
+ stroke="currentColor"
844
+ strokeWidth="2"
845
+ >
846
+ <rect x="8" y="8" width="12" height="12" rx="2" />
847
+ <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
848
+ </svg>
849
+ )}
850
+ </button>
132
851
  )}
133
852
  </label>
134
- <input
135
- type="password"
136
- className={`kyro-form-input ${error ? "kyro-form-input-error" : ""}`}
137
- value={value || ""}
138
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
139
- disabled={disabled}
140
- placeholder={`Enter ${field.label || field.name}`}
141
- />
853
+ {field.name === "slug" ? (
854
+ <div className="flex items-center gap-2">
855
+ <div className="relative flex-1">
856
+ <input
857
+ type="text"
858
+ className={`kyro-form-input pr-24 ${isSlugLocked ? "opacity-70 bg-[var(--kyro-bg-secondary)]" : ""}`}
859
+ value={value || ""}
860
+ onChange={(e) => onFieldChange(e.target.value)}
861
+ disabled={isSlugLocked || disabled}
862
+ />
863
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
864
+ {!isSlugLocked && (
865
+ <button
866
+ type="button"
867
+ onClick={() =>
868
+ onFieldChange(
869
+ slugifyText(
870
+ formData[field.admin?.autoGenerate || "title"] ||
871
+ "",
872
+ ),
873
+ )
874
+ }
875
+ className="p-1 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)]"
876
+ >
877
+ <svg
878
+ width="12"
879
+ height="12"
880
+ viewBox="0 0 24 24"
881
+ fill="none"
882
+ stroke="currentColor"
883
+ strokeWidth="2.5"
884
+ >
885
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
886
+ <path d="M21 3v5h-5" />
887
+ </svg>
888
+ </button>
889
+ )}
890
+ <button
891
+ type="button"
892
+ onClick={() => setIsSlugLocked(!isSlugLocked)}
893
+ className={`p-1.5 rounded ${isSlugLocked ? "text-[var(--kyro-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
894
+ >
895
+ {isSlugLocked ? (
896
+ <svg
897
+ width="12"
898
+ height="12"
899
+ viewBox="0 0 24 24"
900
+ fill="none"
901
+ stroke="currentColor"
902
+ strokeWidth="2.5"
903
+ >
904
+ <rect
905
+ x="3"
906
+ y="11"
907
+ width="18"
908
+ height="11"
909
+ rx="2"
910
+ ry="2"
911
+ />
912
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
913
+ </svg>
914
+ ) : (
915
+ <svg
916
+ width="12"
917
+ height="12"
918
+ viewBox="0 0 24 24"
919
+ fill="none"
920
+ stroke="currentColor"
921
+ strokeWidth="2.5"
922
+ >
923
+ <rect
924
+ x="3"
925
+ y="11"
926
+ width="18"
927
+ height="11"
928
+ rx="2"
929
+ ry="2"
930
+ />
931
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
932
+ </svg>
933
+ )}
934
+ </button>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ ) : (
939
+ <input
940
+ type={(field as any).variant === "url" ? "url" : "text"}
941
+ className="kyro-form-input"
942
+ value={value || ""}
943
+ onChange={(e) => onFieldChange(e.target.value)}
944
+ disabled={disabled}
945
+ />
946
+ )}
947
+ {field.name?.toLowerCase().includes("metatitle") && (
948
+ <div className="flex items-center justify-between mt-1 text-[10px] font-bold uppercase tracking-wider">
949
+ <span
950
+ className={
951
+ (value?.length || 0) > 60
952
+ ? "text-red-500"
953
+ : (value?.length || 0) >= 40
954
+ ? "text-green-500"
955
+ : "text-amber-600"
956
+ }
957
+ >
958
+ {value?.length || 0} / 60 —{" "}
959
+ {(value?.length || 0) > 60
960
+ ? "Too Long"
961
+ : (value?.length || 0) >= 40
962
+ ? "Ideal"
963
+ : "Short"}
964
+ </span>
965
+ </div>
966
+ )}
142
967
  {error && <p className="kyro-form-error">{error}</p>}
143
968
  </div>
144
969
  );
@@ -148,25 +973,157 @@ export function AutoForm({
148
973
  <div key={field.name} className="kyro-form-field">
149
974
  <label className="kyro-form-label">
150
975
  {field.label || field.name}
151
- {field.required && (
152
- <span className="kyro-form-label-required">*</span>
153
- )}
154
976
  </label>
155
977
  <textarea
156
- className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
978
+ className="kyro-form-input kyro-form-textarea"
157
979
  value={value || ""}
158
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
980
+ onChange={(e) => onFieldChange(e.target.value)}
159
981
  disabled={disabled}
160
- rows={(field as any).rows || 4}
161
- placeholder={`Enter ${field.label || field.name}`}
982
+ rows={4}
162
983
  />
163
- {field.admin?.description && !error && (
164
- <p className="kyro-form-help">{field.admin.description}</p>
984
+ {field.name?.toLowerCase().includes("metadescription") && (
985
+ <div className="mt-1 text-[10px] font-bold uppercase tracking-wider">
986
+ <span
987
+ className={
988
+ (value?.length || 0) > 160
989
+ ? "text-red-500"
990
+ : (value?.length || 0) >= 120
991
+ ? "text-green-500"
992
+ : "text-amber-600"
993
+ }
994
+ >
995
+ {value?.length || 0} / 160 —{" "}
996
+ {(value?.length || 0) > 160
997
+ ? "Too Long"
998
+ : (value?.length || 0) >= 120
999
+ ? "Ideal"
1000
+ : "Short"}
1001
+ </span>
1002
+ </div>
165
1003
  )}
166
- {error && <p className="kyro-form-error">{error}</p>}
167
1004
  </div>
168
1005
  );
169
1006
 
1007
+ case "richtext":
1008
+ return (field as any).hasBlocks === false ? (
1009
+ <PortableTextField
1010
+ key={field.name}
1011
+ field={field as any}
1012
+ value={value}
1013
+ onChange={(newValue: any) => onFieldChange(newValue)}
1014
+ disabled={disabled}
1015
+ error={error}
1016
+ />
1017
+ ) : (
1018
+ <BlocksField
1019
+ key={field.name}
1020
+ field={field as any}
1021
+ value={value}
1022
+ onChange={(newValue: any) => onFieldChange(newValue)}
1023
+ disabled={disabled}
1024
+ error={error}
1025
+ />
1026
+ );
1027
+
1028
+ case "group":
1029
+ if ("fields" in field) {
1030
+ const groupData = value || {};
1031
+ return (
1032
+ <div key={field.name} className="kyro-form-group">
1033
+ <h3 className="kyro-form-group-title">
1034
+ {field.label || field.name}
1035
+ </h3>
1036
+ <div className="kyro-form-group-fields">
1037
+ {(field as any).fields.map((f: Field) =>
1038
+ renderField(f, groupData, onFieldChange),
1039
+ )}
1040
+ </div>
1041
+ </div>
1042
+ );
1043
+ }
1044
+ return null;
1045
+
1046
+ case "array":
1047
+ if ("fields" in field) {
1048
+ const items = Array.isArray(value) ? value : [];
1049
+ const labelField = (field as any).fields?.[0]?.name || "user";
1050
+ const isRelationship =
1051
+ (field as any).fields?.[0]?.type === "relationship";
1052
+ return (
1053
+ <div key={field.name} className="kyro-form-field">
1054
+ <label className="kyro-form-label">
1055
+ {field.label || field.name}
1056
+ </label>
1057
+ {isRelationship ? (
1058
+ <RelationshipField
1059
+ field={{
1060
+ name: labelField,
1061
+ relationTo: (field as any).fields[0].relationTo,
1062
+ hasMany: true,
1063
+ label: (field as any).fields[0].label,
1064
+ }}
1065
+ value={items.map((i: any) => i[labelField]).filter(Boolean)}
1066
+ onChange={(newValue: any) => {
1067
+ const newItems = (newValue || []).map((id: string) => ({
1068
+ [labelField]: id,
1069
+ }));
1070
+ onFieldChange(newItems);
1071
+ }}
1072
+ disabled={disabled}
1073
+ />
1074
+ ) : (
1075
+ <div className="kyro-form-array">
1076
+ {items.map((item: any, index: number) => (
1077
+ <div key={index} className="kyro-form-array-item">
1078
+ <div className="flex justify-between mb-2">
1079
+ <span className="text-xs font-bold opacity-50">
1080
+ Item {index + 1}
1081
+ </span>
1082
+ <button
1083
+ type="button"
1084
+ className="text-red-500"
1085
+ onClick={() =>
1086
+ onFieldChange(items.filter((_, i) => i !== index))
1087
+ }
1088
+ >
1089
+ Remove
1090
+ </button>
1091
+ </div>
1092
+ {(field as any).fields.map((f: Field) =>
1093
+ renderField(f, item, (newItem) => {
1094
+ const newItems = [...items];
1095
+ newItems[index] = newItem;
1096
+ onFieldChange(newItems);
1097
+ }),
1098
+ )}
1099
+ </div>
1100
+ ))}
1101
+ <button
1102
+ type="button"
1103
+ className="kyro-btn kyro-btn-secondary kyro-btn-sm"
1104
+ onClick={() => onFieldChange([...items, {}])}
1105
+ >
1106
+ Add Item
1107
+ </button>
1108
+ </div>
1109
+ )}
1110
+ </div>
1111
+ );
1112
+ }
1113
+ return null;
1114
+
1115
+ case "blocks":
1116
+ return (
1117
+ <BlocksField
1118
+ key={field.name}
1119
+ field={field as any}
1120
+ value={value}
1121
+ onChange={(newValue: any) => onFieldChange(newValue)}
1122
+ disabled={disabled}
1123
+ error={error}
1124
+ />
1125
+ );
1126
+
170
1127
  case "number":
171
1128
  return (
172
1129
  <div key={field.name} className="kyro-form-field">
@@ -176,49 +1133,31 @@ export function AutoForm({
176
1133
  <span className="kyro-form-label-required">*</span>
177
1134
  )}
178
1135
  </label>
179
- <input
180
- type="number"
181
- className={`kyro-form-input kyro-form-number ${error ? "kyro-form-input-error" : ""}`}
182
- value={value ?? ""}
183
- onChange={(e) =>
184
- handleFieldChange(field.name!, parseFloat(e.target.value) || 0)
185
- }
1136
+ <NumberField
1137
+ field={field as any}
1138
+ value={value}
1139
+ onChange={(newValue) => onFieldChange(newValue)}
186
1140
  disabled={disabled}
187
- placeholder="0"
188
- min={(field as any).min}
189
- max={(field as any).max}
190
- step={(field as any).step || "any"}
1141
+ error={error}
191
1142
  />
192
- {field.admin?.description && !error && (
193
- <p className="kyro-form-help">{field.admin.description}</p>
194
- )}
195
- {error && <p className="kyro-form-error">{error}</p>}
196
1143
  </div>
197
1144
  );
198
1145
 
199
- case "checkbox":
200
- return (
201
- <div key={field.name} className="kyro-form-field">
202
- <label className="kyro-form-checkbox">
203
- <input
204
- type="checkbox"
205
- checked={value || false}
206
- onChange={(e) =>
207
- handleFieldChange(field.name!, e.target.checked)
208
- }
209
- disabled={disabled}
210
- />
211
- <span className="kyro-form-checkbox-label">
212
- {field.label || field.name}
213
- </span>
214
- </label>
215
- {field.admin?.description && (
216
- <p className="kyro-form-help">{field.admin.description}</p>
217
- )}
1146
+ case "checkbox":
1147
+ return (
1148
+ <div key={field.name} className="kyro-form-field">
1149
+ <CheckboxField
1150
+ field={field as any}
1151
+ value={value}
1152
+ onChange={(newValue) => onFieldChange(newValue)}
1153
+ disabled={disabled}
1154
+ error={error}
1155
+ />
1156
+ {error && <p className="kyro-form-error">{error}</p>}
218
1157
  </div>
219
1158
  );
220
1159
 
221
- case "date":
1160
+ case "select":
222
1161
  return (
223
1162
  <div key={field.name} className="kyro-form-field">
224
1163
  <label className="kyro-form-label">
@@ -227,23 +1166,18 @@ export function AutoForm({
227
1166
  <span className="kyro-form-label-required">*</span>
228
1167
  )}
229
1168
  </label>
230
- <input
231
- type="date"
232
- className={`kyro-form-input ${error ? "kyro-form-input-error" : ""}`}
233
- value={value ? new Date(value).toISOString().split("T")[0] : ""}
234
- onChange={(e) =>
235
- handleFieldChange(
236
- field.name!,
237
- e.target.value ? new Date(e.target.value) : null,
238
- )
239
- }
1169
+ <SelectField
1170
+ field={field as any}
1171
+ value={value}
1172
+ onChange={(newValue) => onFieldChange(newValue)}
240
1173
  disabled={disabled}
1174
+ error={error}
241
1175
  />
242
1176
  {error && <p className="kyro-form-error">{error}</p>}
243
1177
  </div>
244
1178
  );
245
1179
 
246
- case "select":
1180
+ case "date":
247
1181
  return (
248
1182
  <div key={field.name} className="kyro-form-field">
249
1183
  <label className="kyro-form-label">
@@ -252,22 +1186,38 @@ export function AutoForm({
252
1186
  <span className="kyro-form-label-required">*</span>
253
1187
  )}
254
1188
  </label>
255
- <select
256
- className={`kyro-form-input kyro-form-select ${error ? "kyro-form-input-error" : ""}`}
1189
+ <DateField
1190
+ field={field as any}
1191
+ value={value}
1192
+ onChange={(newValue) => onFieldChange(newValue)}
1193
+ disabled={disabled}
1194
+ error={error}
1195
+ />
1196
+ {error && <p className="kyro-form-error">{error}</p>}
1197
+ </div>
1198
+ );
1199
+
1200
+ case "password":
1201
+ return (
1202
+ <div key={field.name} className="kyro-form-field">
1203
+ <label className="kyro-form-label flex items-center justify-between">
1204
+ <div className="flex items-center gap-2">
1205
+ {field.label || field.name}
1206
+ {field.required && (
1207
+ <span className="kyro-form-label-required">*</span>
1208
+ )}
1209
+ </div>
1210
+ </label>
1211
+ <input
1212
+ type="password"
1213
+ className="kyro-form-input"
257
1214
  value={value || ""}
258
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
1215
+ onChange={(e) => onFieldChange(e.target.value)}
259
1216
  disabled={disabled}
260
- >
261
- <option value="">Select {field.label || field.name}</option>
262
- {(field as any).options?.map((opt: any) => (
263
- <option key={opt.value || opt} value={opt.value || opt}>
264
- {opt.label || opt}
265
- </option>
266
- ))}
267
- </select>
268
- {field.admin?.description && !error && (
269
- <p className="kyro-form-help">{field.admin.description}</p>
270
- )}
1217
+ placeholder={
1218
+ field.admin?.placeholder || `Enter ${field.label || field.name}`
1219
+ }
1220
+ />
271
1221
  {error && <p className="kyro-form-error">{error}</p>}
272
1222
  </div>
273
1223
  );
@@ -282,19 +1232,18 @@ export function AutoForm({
282
1232
  )}
283
1233
  </label>
284
1234
  <div className="kyro-form-radio-group">
285
- {(field as any).options?.map((opt: any) => (
286
- <label key={opt.value || opt} className="kyro-form-radio">
1235
+ {((field as any).options || []).map((opt: any) => (
1236
+ <label key={opt.value} className="kyro-form-radio-label">
287
1237
  <input
288
1238
  type="radio"
289
1239
  name={field.name}
290
- value={opt.value || opt}
291
- checked={value === (opt.value || opt)}
292
- onChange={(e) =>
293
- handleFieldChange(field.name!, e.target.value)
294
- }
1240
+ value={opt.value}
1241
+ checked={value === opt.value}
1242
+ onChange={() => onFieldChange(opt.value)}
295
1243
  disabled={disabled}
1244
+ className="kyro-form-radio"
296
1245
  />
297
- <span>{opt.label || opt}</span>
1246
+ <span>{opt.label || opt.value}</span>
298
1247
  </label>
299
1248
  ))}
300
1249
  </div>
@@ -305,635 +1254,1014 @@ export function AutoForm({
305
1254
  case "color":
306
1255
  return (
307
1256
  <div key={field.name} className="kyro-form-field">
308
- <label className="kyro-form-label">
1257
+ <label className="kyro-form-label flex items-center gap-2">
309
1258
  {field.label || field.name}
310
1259
  {field.required && (
311
1260
  <span className="kyro-form-label-required">*</span>
312
1261
  )}
1262
+ {value && (
1263
+ <span
1264
+ className="w-5 h-5 rounded border border-[var(--kyro-border)] shrink-0"
1265
+ style={{ backgroundColor: value }}
1266
+ />
1267
+ )}
313
1268
  </label>
314
- <div className="kyro-form-color-wrapper">
1269
+ <div className="flex items-center gap-3">
315
1270
  <input
316
1271
  type="color"
317
- className={`kyro-form-color ${error ? "kyro-form-input-error" : ""}`}
318
1272
  value={value || "#000000"}
319
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
1273
+ onChange={(e) => onFieldChange(e.target.value)}
320
1274
  disabled={disabled}
1275
+ className="kyro-form-input h-10 w-14 p-1 cursor-pointer"
1276
+ />
1277
+ <input
1278
+ type="text"
1279
+ className="kyro-form-input font-mono uppercase"
1280
+ value={value || ""}
1281
+ onChange={(e) => onFieldChange(e.target.value)}
1282
+ disabled={disabled}
1283
+ placeholder="#000000"
321
1284
  />
322
- <span className="kyro-form-color-value">
323
- {value || "#000000"}
324
- </span>
325
1285
  </div>
326
1286
  {error && <p className="kyro-form-error">{error}</p>}
327
1287
  </div>
328
1288
  );
329
1289
 
330
- case "json":
1290
+ case "markdown":
1291
+ return (
1292
+ <MarkdownField
1293
+ key={field.name}
1294
+ field={field as any}
1295
+ value={value || ""}
1296
+ onChange={(val) => onFieldChange(val)}
1297
+ disabled={disabled}
1298
+ />
1299
+ );
1300
+
1301
+ case "button": {
1302
+ const isLoading = loadingFields[field.name!];
331
1303
  return (
332
1304
  <div key={field.name} className="kyro-form-field">
333
- <label className="kyro-form-label">
334
- {field.label || field.name}
335
- {field.required && (
336
- <span className="kyro-form-label-required">*</span>
337
- )}
338
- </label>
339
- <textarea
340
- className={`kyro-form-input kyro-form-textarea kyro-form-code ${error ? "kyro-form-input-error" : ""}`}
341
- value={
342
- typeof value === "string"
343
- ? value
344
- : JSON.stringify(value || {}, null, 2)
345
- }
346
- onChange={(e) => {
347
- try {
348
- handleFieldChange(field.name!, JSON.parse(e.target.value));
349
- } catch {
350
- handleFieldChange(field.name!, e.target.value);
1305
+ <button
1306
+ type="button"
1307
+ disabled={isLoading || disabled}
1308
+ onClick={async () => {
1309
+ const action = field.admin?.action || (field as any).action;
1310
+ const method =
1311
+ field.admin?.method || (field as any).method || "POST";
1312
+ if (action) {
1313
+ setLoadingFields((prev) => ({
1314
+ ...prev,
1315
+ [field.name!]: true,
1316
+ }));
1317
+ try {
1318
+ const response = await fetch(action, {
1319
+ method,
1320
+ headers: { "Content-Type": "application/json" },
1321
+ body: JSON.stringify(formData),
1322
+ });
1323
+ const result = await response.json();
1324
+ if (response.ok) {
1325
+ // handle result
1326
+ } else {
1327
+ // handle error
1328
+ }
1329
+ } catch (err) {
1330
+ console.error("Error executing action:", err);
1331
+ } finally {
1332
+ setLoadingFields((prev) => ({
1333
+ ...prev,
1334
+ [field.name!]: false,
1335
+ }));
1336
+ }
351
1337
  }
352
1338
  }}
353
- disabled={disabled}
354
- rows={6}
355
- placeholder='{"key": "value"}'
356
- />
357
- {field.admin?.description && !error && (
358
- <p className="kyro-form-help">{field.admin.description}</p>
359
- )}
360
- {error && <p className="kyro-form-error">{error}</p>}
1339
+ className={`kyro-btn kyro-btn-md kyro-btn-secondary transition-all active:scale-95 whitespace-nowrap flex items-center gap-2 ${isLoading ? "opacity-70 cursor-not-allowed" : ""}`}
1340
+ >
1341
+ {isLoading && (
1342
+ <svg
1343
+ className="animate-spin h-3 w-3 text-white"
1344
+ xmlns="http://www.w3.org/2000/svg"
1345
+ fill="none"
1346
+ viewBox="0 0 24 24"
1347
+ >
1348
+ <circle
1349
+ className="opacity-25"
1350
+ cx="12"
1351
+ cy="12"
1352
+ r="10"
1353
+ stroke="currentColor"
1354
+ strokeWidth="4"
1355
+ ></circle>
1356
+ <path
1357
+ className="opacity-75"
1358
+ fill="currentColor"
1359
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
1360
+ ></path>
1361
+ </svg>
1362
+ )}
1363
+ {isLoading ? "Processing..." : field.label || "Click"}
1364
+ </button>
361
1365
  </div>
362
1366
  );
1367
+ }
363
1368
 
364
- case "markdown":
1369
+ case "relationship":
365
1370
  return (
366
- <div key={field.name} className="kyro-form-field">
367
- <label className="kyro-form-label">
368
- {field.label || field.name}
369
- {field.required && (
370
- <span className="kyro-form-label-required">*</span>
371
- )}
372
- </label>
373
- <textarea
374
- className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
375
- value={value || ""}
376
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
377
- disabled={disabled}
378
- rows={8}
379
- placeholder="Enter markdown content..."
380
- />
381
- {field.admin?.description && !error && (
382
- <p className="kyro-form-help">{field.admin.description}</p>
383
- )}
384
- {error && <p className="kyro-form-error">{error}</p>}
385
- </div>
1371
+ <RelationshipField
1372
+ key={field.name}
1373
+ field={field as any}
1374
+ value={value}
1375
+ onChange={(newValue) => onFieldChange(newValue)}
1376
+ disabled={disabled}
1377
+ error={error}
1378
+ />
386
1379
  );
387
1380
 
388
1381
  case "code":
389
1382
  return (
390
- <div key={field.name} className="kyro-form-field">
391
- <label className="kyro-form-label">
392
- {field.label || field.name}
393
- {field.required && (
394
- <span className="kyro-form-label-required">*</span>
395
- )}
396
- </label>
397
- <textarea
398
- className={`kyro-form-input kyro-form-textarea kyro-form-code ${error ? "kyro-form-input-error" : ""}`}
399
- value={value || ""}
400
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
401
- disabled={disabled}
402
- rows={8}
403
- placeholder="Enter code..."
404
- />
405
- {field.admin?.description && !error && (
406
- <p className="kyro-form-help">{field.admin.description}</p>
407
- )}
408
- {error && <p className="kyro-form-error">{error}</p>}
409
- </div>
1383
+ <CodeField
1384
+ key={field.name}
1385
+ field={field as any}
1386
+ value={value || ""}
1387
+ onChange={(newValue) => onFieldChange(newValue)}
1388
+ disabled={disabled}
1389
+ error={error}
1390
+ />
410
1391
  );
411
1392
 
412
- case "group":
413
- if ("fields" in field) {
414
- const groupData = value || {};
415
- return (
416
- <div key={field.name} className="kyro-form-group">
417
- <h3 className="kyro-form-group-title">
418
- {field.label || field.name}
419
- </h3>
420
- <div className="kyro-form-group-fields">
421
- {(field as any).fields.map((f: Field) =>
422
- renderField(f, groupData),
423
- )}
424
- </div>
425
- </div>
426
- );
427
- }
1393
+ // @ts-ignore - 'image' is supported but not in the standard union yet
1394
+ case "image":
1395
+ case "upload":
1396
+ return (
1397
+ <UploadField
1398
+ key={field.name}
1399
+ field={field as any}
1400
+ value={value}
1401
+ onChange={(newValue) => onFieldChange(newValue)}
1402
+ disabled={disabled}
1403
+ />
1404
+ );
1405
+
1406
+ default:
428
1407
  return null;
1408
+ }
1409
+ };
429
1410
 
430
- case "array":
431
- if ("fields" in field) {
432
- const items = Array.isArray(value) ? value : [];
433
- const labels = (field as any).labels || {
434
- singular: "Item",
435
- plural: "Items",
436
- };
437
- return (
438
- <div key={field.name} className="kyro-form-field">
439
- <label className="kyro-form-label">
440
- {field.label || field.name}
441
- </label>
442
- <div className="kyro-form-array">
443
- {items.length === 0 ? (
444
- <p className="kyro-form-array-empty">
445
- No {labels.plural.toLowerCase()} yet
446
- </p>
447
- ) : (
448
- items.map((item: any, index: number) => (
449
- <div key={index} className="kyro-form-array-item">
450
- <div className="kyro-form-array-item-header">
451
- <span className="kyro-form-array-item-number">
452
- {index + 1}
453
- </span>
454
- <button
455
- type="button"
456
- className="kyro-form-array-item-remove"
457
- onClick={() => {
458
- const newItems = items.filter(
459
- (_: any, i: number) => i !== index,
460
- );
461
- handleFieldChange(field.name!, newItems);
462
- }}
463
- disabled={disabled}
464
- >
465
- <svg
466
- width="14"
467
- height="14"
468
- viewBox="0 0 24 24"
469
- fill="none"
470
- stroke="currentColor"
471
- strokeWidth="2"
472
- >
473
- <path d="M18 6L6 18M6 6l12 12" />
474
- </svg>
475
- </button>
476
- </div>
477
- <div className="kyro-form-array-item-fields">
478
- {(field as any).fields.map((f: Field) => {
479
- const fieldKey = f.name!;
480
- const subFieldValue = item[fieldKey];
481
- const handleSubFieldChange = (newValue: any) => {
482
- const newItem = { ...item, [fieldKey]: newValue };
483
- const newItems = [...items];
484
- newItems[index] = newItem;
485
- handleFieldChange(field.name!, newItems);
486
- };
487
- return (
488
- <div key={fieldKey} className="kyro-form-field">
489
- <label className="kyro-form-label">
490
- {f.label || f.name}
491
- </label>
492
- {renderSubField(
493
- f,
494
- subFieldValue,
495
- handleSubFieldChange,
496
- disabled,
497
- )}
498
- </div>
499
- );
500
- })}
501
- </div>
502
- </div>
503
- ))
504
- )}
1411
+ const renderHeader = () => {
1412
+ const docTitle = formData.title || formData.name || "Untitled";
1413
+ const status = formData.status || "draft";
1414
+ const isNew = !formData.id;
1415
+ const lastModified = formData.updatedAt
1416
+ ? new Date(formData.updatedAt).toLocaleString()
1417
+ : "Just now";
1418
+ const createdAt = formData.createdAt
1419
+ ? new Date(formData.createdAt).toLocaleString()
1420
+ : "Just now";
1421
+
1422
+ return (
1423
+ <header className="surface-tile px-8 py-6 flex items-center justify-between sticky top-0 z-50 border-b border-[var(--kyro-border)] mb-8 bg-[var(--kyro-surface)] backdrop-blur-md">
1424
+ <div className="flex flex-col gap-1">
1425
+ <div className="flex items-center gap-4">
1426
+ <a
1427
+ href={`/${collectionSlug}`}
1428
+ className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
1429
+ >
1430
+ <svg
1431
+ className="w-4 h-4"
1432
+ fill="none"
1433
+ stroke="currentColor"
1434
+ viewBox="0 0 24 24"
1435
+ >
1436
+ <path
1437
+ strokeLinecap="round"
1438
+ strokeLinejoin="round"
1439
+ strokeWidth="2.5"
1440
+ d="M15 19l-7-7 7-7"
1441
+ />
1442
+ </svg>
1443
+ </a>
1444
+ <h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
1445
+ </div>
1446
+ <div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
1447
+ <span className="flex items-center gap-1.5 capitalize">
1448
+ <span
1449
+ className={`h-1.5 w-1.5 rounded-full ${status === "published" && !hasUnsavedChanges ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
1450
+ />
1451
+ {hasUnsavedChanges ? "Draft" : status}
1452
+ </span>
1453
+ {autoSaveStatus === "saving" && (
1454
+ <span className="flex items-center gap-1.5 text-[var(--kyro-text-muted)]">
1455
+ <svg
1456
+ className="animate-spin h-3 w-3"
1457
+ viewBox="0 0 24 24"
1458
+ fill="none"
1459
+ >
1460
+ <circle
1461
+ className="opacity-25"
1462
+ cx="12"
1463
+ cy="12"
1464
+ r="10"
1465
+ stroke="currentColor"
1466
+ strokeWidth="4"
1467
+ />
1468
+ <path
1469
+ className="opacity-75"
1470
+ fill="currentColor"
1471
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
1472
+ />
1473
+ </svg>
1474
+ Saving...
1475
+ </span>
1476
+ )}
1477
+ {autoSaveStatus === "saved" && (
1478
+ <span className="text-[var(--kyro-success)] flex items-center gap-1">
1479
+ <svg
1480
+ width="12"
1481
+ height="12"
1482
+ viewBox="0 0 24 24"
1483
+ fill="none"
1484
+ stroke="currentColor"
1485
+ strokeWidth="3"
1486
+ >
1487
+ <path d="M20 6L9 17l-5-5" />
1488
+ </svg>
1489
+ Saved
1490
+ </span>
1491
+ )}
1492
+ {autoSaveStatus === "error" && (
1493
+ <span className="text-[var(--kyro-danger)]">Save failed</span>
1494
+ )}
1495
+ {hasUnsavedChanges && autoSaveStatus !== "saving" && (
1496
+ <>
1497
+ <span className="opacity-30">—</span>
505
1498
  <button
506
1499
  type="button"
507
- className="kyro-btn kyro-btn-secondary kyro-btn-sm"
508
- onClick={() => {
509
- const newItem: Record<string, any> = {};
510
- (field as any).fields.forEach((f: Field) => {
511
- if (f.defaultValue !== undefined) {
512
- newItem[f.name!] = f.defaultValue;
513
- }
514
- });
515
- handleFieldChange(field.name!, [...items, newItem]);
516
- }}
517
- disabled={disabled}
1500
+ onClick={() => setFormData(lastSavedData)}
1501
+ className="text-[var(--kyro-primary)] hover:underline"
518
1502
  >
519
- <svg
520
- width="14"
521
- height="14"
522
- viewBox="0 0 24 24"
523
- fill="none"
524
- stroke="currentColor"
525
- strokeWidth="2"
526
- >
527
- <path d="M12 5v14M5 12h14" />
528
- </svg>
529
- Add {labels.singular}
1503
+ Revert changes
530
1504
  </button>
531
- </div>
532
- </div>
533
- );
534
- }
535
- return null;
1505
+ </>
1506
+ )}
1507
+ <span className="border-l border-[var(--kyro-border)] pl-4">
1508
+ Modified {lastModified}
1509
+ </span>
1510
+ <span className="border-l border-[var(--kyro-border)] pl-4">
1511
+ Created {createdAt}
1512
+ </span>
1513
+ </div>
1514
+ </div>
536
1515
 
537
- case "blocks":
538
- if ("blocks" in field) {
539
- const blocks = Array.isArray(value) ? value : [];
540
- const labels = (field as any).labels || {
541
- singular: "Block",
542
- plural: "Blocks",
543
- };
544
- return (
545
- <div key={field.name} className="kyro-form-field">
546
- <label className="kyro-form-label">
547
- {field.label || field.name}
548
- </label>
549
- <div className="kyro-form-blocks">
550
- {blocks.length === 0 ? (
551
- <p className="kyro-form-blocks-empty">
552
- No {labels.plural.toLowerCase()} yet
553
- </p>
554
- ) : (
555
- blocks.map((block: any, index: number) => (
556
- <div key={index} className="kyro-form-block-item">
557
- <div className="kyro-form-block-item-header">
558
- <span className="kyro-form-block-item-type">
559
- {(field as any).blocks.find(
560
- (b: Block) => b.slug === block.blockType,
561
- )?.label || block.blockType}
562
- </span>
563
- <div className="kyro-form-block-item-actions">
564
- <button
565
- type="button"
566
- className="kyro-form-block-item-move"
567
- onClick={() => {
568
- if (index > 0) {
569
- const newBlocks = [...blocks];
570
- [newBlocks[index - 1], newBlocks[index]] = [
571
- newBlocks[index],
572
- newBlocks[index - 1],
573
- ];
574
- handleFieldChange(field.name!, newBlocks);
575
- }
576
- }}
577
- disabled={disabled || index === 0}
578
- >
579
- <svg
580
- width="14"
581
- height="14"
582
- viewBox="0 0 24 24"
583
- fill="none"
584
- stroke="currentColor"
585
- strokeWidth="2"
586
- >
587
- <path d="M18 15l-6-6-6 6" />
588
- </svg>
589
- </button>
590
- <button
591
- type="button"
592
- className="kyro-form-block-item-remove"
593
- onClick={() => {
594
- const newBlocks = blocks.filter(
595
- (_: any, i: number) => i !== index,
596
- );
597
- handleFieldChange((field as any).name, newBlocks);
598
- }}
599
- disabled={disabled}
1516
+ <div className="flex items-center gap-6">
1517
+ <div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
1518
+ {["edit", "version", "api"].map((v) => (
1519
+ <button
1520
+ key={v}
1521
+ type="button"
1522
+ onClick={() => setView(v as any)}
1523
+ className={`px-5 py-2 text-xs font-black rounded-lg transition-all ${view === v ? "bg-[var(--kyro-surface)] shadow-sm border border-[var(--kyro-border)] text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
1524
+ >
1525
+ {v.toUpperCase()}
1526
+ </button>
1527
+ ))}
1528
+ </div>
1529
+
1530
+ <div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
1531
+
1532
+ <div className="flex items-center gap-3">
1533
+ <button
1534
+ type="button"
1535
+ onClick={() => setShowPreview(!showPreview)}
1536
+ className={`p-2.5 rounded-xl transition-all flex items-center gap-2 ${showPreview ? "bg-[var(--kyro-primary)] text-white shadow-lg" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
1537
+ title="Live Preview"
1538
+ >
1539
+ <svg
1540
+ width="20"
1541
+ height="20"
1542
+ viewBox="0 0 24 24"
1543
+ fill="none"
1544
+ stroke="currentColor"
1545
+ strokeWidth="2"
1546
+ >
1547
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" />
1548
+ </svg>
1549
+ {showPreview && (
1550
+ <span className="text-[10px] font-black uppercase tracking-widest pr-1">
1551
+ Active
1552
+ </span>
1553
+ )}
1554
+ </button>
1555
+ <button
1556
+ type="button"
1557
+ onClick={() => {
1558
+ window.dispatchEvent(new CustomEvent("toggle-sidebar"));
1559
+ }}
1560
+ className={`p-2.5 rounded-xl transition-all ${sidebarCollapsed ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)]"}`}
1561
+ title="Toggle Sidebar"
1562
+ >
1563
+ <svg
1564
+ width="20"
1565
+ height="20"
1566
+ viewBox="0 0 24 24"
1567
+ fill="none"
1568
+ stroke="currentColor"
1569
+ strokeWidth="2"
1570
+ >
1571
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
1572
+ <line x1="9" y1="3" x2="9" y2="21" />
1573
+ </svg>
1574
+ </button>
1575
+
1576
+ <button
1577
+ id="btn-save"
1578
+ type="button"
1579
+ onClick={async () => {
1580
+ const hiddenInput = document.getElementById(
1581
+ "form-data",
1582
+ ) as HTMLInputElement;
1583
+ if (!hiddenInput || !hiddenInput.value) return;
1584
+
1585
+ const btn = document.getElementById(
1586
+ "btn-save",
1587
+ ) as HTMLButtonElement;
1588
+ const originalText = btn?.textContent || "";
1589
+ if (btn) {
1590
+ btn.textContent = "Saving...";
1591
+ btn.setAttribute("disabled", "true");
1592
+ }
1593
+
1594
+ try {
1595
+ const data = JSON.parse(hiddenInput.value);
1596
+ const url = isNew
1597
+ ? `/api/${collectionSlug}`
1598
+ : `/api/${collectionSlug}/${formData.id}`;
1599
+ const method = isNew ? "POST" : "PATCH";
1600
+
1601
+ const response = await fetch(url, {
1602
+ method,
1603
+ credentials: "include",
1604
+ headers: { "Content-Type": "application/json" },
1605
+ body: JSON.stringify(data),
1606
+ });
1607
+
1608
+ if (response.ok) {
1609
+ const result = await response.json();
1610
+ setLastSavedData(result.data || formData);
1611
+ lastAutoSaveTimeRef.current = Date.now();
1612
+ setAutoSaveStatus("saved");
1613
+ fetchVersions();
1614
+ setTimeout(() => setAutoSaveStatus("idle"), 2000);
1615
+ onActionSuccess?.(
1616
+ isNew ? "Document created successfully" : "Changes saved",
1617
+ );
1618
+ if (isNew) {
1619
+ setTimeout(() => {
1620
+ window.location.href = `/${collectionSlug}`;
1621
+ }, 800);
1622
+ }
1623
+ } else {
1624
+ const error = await response.json();
1625
+ setAlertModal({
1626
+ open: true,
1627
+ title: "Error",
1628
+ message: error.error || "Failed to save",
1629
+ });
1630
+ }
1631
+ } catch (err) {
1632
+ setAlertModal({
1633
+ open: true,
1634
+ title: "Error",
1635
+ message: "Failed to save document",
1636
+ });
1637
+ } finally {
1638
+ if (btn) {
1639
+ btn.textContent = originalText;
1640
+ btn.removeAttribute("disabled");
1641
+ }
1642
+ }
1643
+ }}
1644
+ className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
1645
+ >
1646
+ {isNew ? "Create" : hasUnsavedChanges ? "Save Draft" : "Saved"}
1647
+ </button>
1648
+
1649
+ {!isNew && status === "draft" && (
1650
+ <button
1651
+ id="btn-publish"
1652
+ type="button"
1653
+ onClick={async () => {
1654
+ const btn = document.getElementById(
1655
+ "btn-publish",
1656
+ ) as HTMLButtonElement;
1657
+ const originalText = btn?.textContent || "";
1658
+ if (btn) {
1659
+ btn.textContent = "Publishing...";
1660
+ btn.setAttribute("disabled", "true");
1661
+ }
1662
+
1663
+ try {
1664
+ const response = await fetch(
1665
+ `/api/${collectionSlug}/${formData.id}/publish`,
1666
+ {
1667
+ method: "POST",
1668
+ credentials: "include",
1669
+ },
1670
+ );
1671
+
1672
+ if (response.ok) {
1673
+ onActionSuccess?.("Published successfully");
1674
+ location.reload();
1675
+ } else {
1676
+ const error = await response.json();
1677
+ setAlertModal({
1678
+ open: true,
1679
+ title: "Error",
1680
+ message: error.error || "Failed to publish",
1681
+ });
1682
+ }
1683
+ } catch (err) {
1684
+ setAlertModal({
1685
+ open: true,
1686
+ title: "Error",
1687
+ message: "Failed to publish",
1688
+ });
1689
+ } finally {
1690
+ if (btn) {
1691
+ btn.textContent = originalText;
1692
+ btn.removeAttribute("disabled");
1693
+ }
1694
+ }
1695
+ }}
1696
+ className="px-6 py-2.5 text-xs font-bold rounded-xl border-2 border-[var(--kyro-border)] text-[var(--kyro-text-primary)] hover:border-[var(--kyro-primary)] hover:bg-[var(--kyro-primary)] hover:text-white transition-all"
1697
+ >
1698
+ Publish
1699
+ </button>
1700
+ )}
1701
+
1702
+ <div ref={menuRef} className="relative">
1703
+ <button
1704
+ type="button"
1705
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
1706
+ className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
1707
+ >
1708
+ <svg
1709
+ width="20"
1710
+ height="20"
1711
+ viewBox="0 0 24 24"
1712
+ fill="none"
1713
+ stroke="currentColor"
1714
+ strokeWidth="3"
1715
+ >
1716
+ <circle cx="12" cy="12" r="1.5" fill="currentColor" />
1717
+ <circle cx="12" cy="5" r="1.5" fill="currentColor" />
1718
+ <circle cx="12" cy="19" r="1.5" fill="currentColor" />
1719
+ </svg>
1720
+ </button>
1721
+ {isMenuOpen && (
1722
+ <div className="absolute right-0 mt-2 w-48 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface)] shadow-2xl z-50 overflow-hidden">
1723
+ <button
1724
+ type="button"
1725
+ onClick={() => {
1726
+ handleCreateNew();
1727
+ setIsMenuOpen(false);
1728
+ }}
1729
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1730
+ >
1731
+ <svg
1732
+ width="16"
1733
+ height="16"
1734
+ viewBox="0 0 24 24"
1735
+ fill="none"
1736
+ stroke="currentColor"
1737
+ strokeWidth="2"
1738
+ >
1739
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1740
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1741
+ </svg>
1742
+ Create New
1743
+ </button>
1744
+ {!isNew && (
1745
+ <>
1746
+ <button
1747
+ type="button"
1748
+ onClick={() => {
1749
+ handleDuplicate();
1750
+ setIsMenuOpen(false);
1751
+ }}
1752
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1753
+ >
1754
+ <svg
1755
+ width="16"
1756
+ height="16"
1757
+ viewBox="0 0 24 24"
1758
+ fill="none"
1759
+ stroke="currentColor"
1760
+ strokeWidth="2"
1761
+ >
1762
+ <rect
1763
+ x="9"
1764
+ y="9"
1765
+ width="13"
1766
+ height="13"
1767
+ rx="2"
1768
+ ry="2"
1769
+ ></rect>
1770
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1771
+ </svg>
1772
+ Duplicate
1773
+ </button>
1774
+ {status === "published" && (
1775
+ <button
1776
+ type="button"
1777
+ onClick={() => {
1778
+ handleUnpublish();
1779
+ setIsMenuOpen(false);
1780
+ }}
1781
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] flex items-center gap-3 transition-colors"
1782
+ >
1783
+ <svg
1784
+ width="16"
1785
+ height="16"
1786
+ viewBox="0 0 24 24"
1787
+ fill="none"
1788
+ stroke="currentColor"
1789
+ strokeWidth="2"
600
1790
  >
601
- <svg
602
- width="14"
603
- height="14"
604
- viewBox="0 0 24 24"
605
- fill="none"
606
- stroke="currentColor"
607
- strokeWidth="2"
608
- >
609
- <path d="M18 6L6 18M6 6l12 12" />
610
- </svg>
611
- </button>
612
- </div>
613
- </div>
614
- <div className="kyro-form-block-item-fields">
615
- {(field as any).blocks
616
- .find((b: Block) => b.slug === block.blockType)
617
- ?.fields?.map((f: Field) => {
618
- if (!f.name) return null;
619
- const fieldKey = f.name;
620
- const blockData = block;
621
- const handleBlockFieldChange = (newValue: any) => {
622
- const newBlock = {
623
- ...blockData,
624
- [fieldKey]: newValue,
625
- };
626
- const newBlocks = [...blocks];
627
- newBlocks[index] = newBlock;
628
- handleFieldChange(field.name!, newBlocks);
629
- };
630
- return (
631
- <div key={fieldKey} className="kyro-form-field">
632
- <label className="kyro-form-label">
633
- {f.label || f.name}
634
- </label>
635
- {renderSubField(
636
- f,
637
- block[fieldKey],
638
- handleBlockFieldChange,
639
- disabled,
640
- )}
641
- </div>
642
- );
643
- })}
644
- </div>
645
- </div>
646
- ))
647
- )}
648
- <div className="kyro-form-blocks-add">
649
- <span className="kyro-form-blocks-add-label">Add block:</span>
650
- <div className="kyro-form-blocks-add-buttons">
651
- {(field as any).blocks.map((block: Block) => (
1791
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
1792
+ <line x1="1" y1="1" x2="23" y2="23"></line>
1793
+ </svg>
1794
+ Unpublish
1795
+ </button>
1796
+ )}
1797
+ <div className="h-px bg-[var(--kyro-border)]" />
652
1798
  <button
653
- key={block.slug}
654
1799
  type="button"
655
- className="kyro-btn kyro-btn-secondary kyro-btn-sm"
656
1800
  onClick={() => {
657
- const newBlock = { blockType: block.slug };
658
- handleFieldChange(field.name!, [...blocks, newBlock]);
1801
+ handleDelete();
1802
+ setIsMenuOpen(false);
659
1803
  }}
660
- disabled={disabled}
1804
+ className="w-full px-4 py-2.5 text-left text-xs font-medium text-red-600 hover:bg-red-50 flex items-center gap-3 transition-colors"
661
1805
  >
662
- {block.label}
1806
+ <svg
1807
+ width="16"
1808
+ height="16"
1809
+ viewBox="0 0 24 24"
1810
+ fill="none"
1811
+ stroke="currentColor"
1812
+ strokeWidth="2"
1813
+ >
1814
+ <polyline points="3 6 5 6 21 6"></polyline>
1815
+ <path d="M19 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"></path>
1816
+ </svg>
1817
+ Delete
663
1818
  </button>
664
- ))}
665
- </div>
1819
+ </>
1820
+ )}
666
1821
  </div>
667
- </div>
668
- </div>
669
- );
670
- }
671
- return null;
672
-
673
- case "relationship":
674
- return (
675
- <RelationshipField
676
- key={field.name}
677
- field={field as any}
678
- value={value}
679
- onChange={(newValue) => handleFieldChange(field.name!, newValue)}
680
- disabled={disabled}
681
- error={error}
682
- />
683
- );
684
-
685
- case "upload":
686
- return (
687
- <UploadField
688
- key={field.name}
689
- field={field as any}
690
- value={value}
691
- onChange={(newValue) => handleFieldChange(field.name!, newValue)}
692
- disabled={disabled}
693
- error={error}
694
- />
695
- );
696
-
697
- case "richtext":
698
- return (
699
- <div key={field.name} className="kyro-form-field">
700
- <label className="kyro-form-label">
701
- {field.label || field.name}
702
- {field.required && (
703
- <span className="kyro-form-label-required">*</span>
704
1822
  )}
705
- </label>
706
- <textarea
707
- className={`kyro-form-input kyro-form-textarea kyro-form-richtext ${error ? "kyro-form-input-error" : ""}`}
708
- value={value || ""}
709
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
710
- disabled={disabled}
711
- rows={8}
712
- placeholder="Enter rich text content..."
713
- />
714
- {field.admin?.description && !error && (
715
- <p className="kyro-form-help">{field.admin.description}</p>
716
- )}
717
- {error && <p className="kyro-form-error">{error}</p>}
718
- </div>
719
- );
720
-
721
- default: {
722
- const anyField = field as any;
723
- return (
724
- <div key={anyField.name} className="kyro-form-field">
725
- <span className="kyro-form-unsupported">
726
- Unsupported field type: {anyField.type}
727
- </span>
1823
+ </div>
728
1824
  </div>
729
- );
730
- }
731
- }
1825
+ </div>
1826
+ </header>
1827
+ );
732
1828
  };
733
1829
 
734
- return (
735
- <div className="kyro-form">
736
- {config.fields.map((field) => renderField(field))}
737
- </div>
738
- );
739
- }
740
-
741
- function renderSubField(
742
- field: Field,
743
- value: any,
744
- onChange: (value: any) => void,
745
- disabled?: boolean,
746
- ) {
747
- switch (field.type) {
748
- case "text":
749
- case "email":
750
- case "password":
1830
+ const renderEditView = () => {
1831
+ // Single layout: no split grid, no sidebar column — just a clean field list
1832
+ if (layout === "single") {
751
1833
  return (
752
- <input
753
- type={field.type === "email" ? "email" : "text"}
754
- className="kyro-form-input"
755
- value={value || ""}
756
- onChange={(e) => onChange(e.target.value)}
757
- disabled={disabled}
758
- />
759
- );
760
- case "number":
761
- return (
762
- <input
763
- type="number"
764
- className="kyro-form-input kyro-form-number"
765
- value={value ?? ""}
766
- onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
767
- disabled={disabled}
768
- />
769
- );
770
- case "checkbox":
771
- return (
772
- <input
773
- type="checkbox"
774
- checked={value || false}
775
- onChange={(e) => onChange(e.target.checked)}
776
- disabled={disabled}
777
- />
778
- );
779
- case "textarea":
780
- return (
781
- <textarea
782
- className="kyro-form-input kyro-form-textarea"
783
- value={value || ""}
784
- onChange={(e) => onChange(e.target.value)}
785
- disabled={disabled}
786
- rows={3}
787
- />
788
- );
789
- case "select":
790
- return (
791
- <select
792
- className="kyro-form-input kyro-form-select"
793
- value={value || ""}
794
- onChange={(e) => onChange(e.target.value)}
795
- disabled={disabled}
796
- >
797
- <option value="">Select...</option>
798
- {(field as any).options?.map((opt: any) => (
799
- <option key={opt.value || opt} value={opt.value || opt}>
800
- {opt.label || opt}
801
- </option>
802
- ))}
803
- </select>
804
- );
805
- default:
806
- return (
807
- <input
808
- type="text"
809
- className="kyro-form-input"
810
- value={value || ""}
811
- onChange={(e) => onChange(e.target.value)}
812
- disabled={disabled}
813
- />
1834
+ <div className="w-full space-y-8">
1835
+ <div className="surface-tile p-8 space-y-8">
1836
+ {config.fields.map((f) => renderField(f))}
1837
+ </div>
1838
+ </div>
814
1839
  );
815
- }
816
- }
817
-
818
- interface UploadFieldProps {
819
- field: any;
820
- value: any;
821
- onChange: (value: any) => void;
822
- disabled?: boolean;
823
- error?: string;
824
- }
825
-
826
- function UploadField({
827
- field,
828
- value,
829
- onChange,
830
- disabled,
831
- error,
832
- }: UploadFieldProps) {
833
- const inputRef = useRef<HTMLInputElement>(null);
834
- const [preview, setPreview] = useState<string | null>(null);
835
- const [isDragging, setIsDragging] = useState(false);
836
-
837
- const isImage = (file: File) => file.type.startsWith("image/");
838
-
839
- const handleFile = (file: File) => {
840
- if (isImage(file)) {
841
- const reader = new FileReader();
842
- reader.onloadend = () => {
843
- setPreview(reader.result as string);
844
- };
845
- reader.readAsDataURL(file);
846
1840
  }
847
- onChange({
848
- filename: file.name,
849
- size: file.size,
850
- type: file.type,
851
- url: URL.createObjectURL(file),
852
- });
853
- };
854
1841
 
855
- const handleDrop = (e: React.DragEvent) => {
856
- e.preventDefault();
857
- setIsDragging(false);
858
- const file = e.dataTransfer.files[0];
859
- if (file) handleFile(file);
860
- };
1842
+ // Default split layout
1843
+ const showRightColumn = !sidebarCollapsed && !showPreview;
1844
+ const hasSidebarFields =
1845
+ config.fields.some((f) => f.admin?.position === "sidebar") &&
1846
+ !showPreview;
861
1847
 
862
- return (
863
- <div className="kyro-form-field">
864
- <label className="kyro-form-label">
865
- {field.label || field.name}
866
- {field.required && <span className="kyro-form-label-required">*</span>}
867
- </label>
1848
+ return (
868
1849
  <div
869
- className={`kyro-form-upload ${isDragging ? "kyro-form-upload-dragging" : ""} ${error ? "kyro-form-upload-error" : ""}`}
870
- onDragOver={(e) => {
871
- e.preventDefault();
872
- setIsDragging(true);
873
- }}
874
- onDragLeave={() => setIsDragging(false)}
875
- onDrop={handleDrop}
876
- onClick={() => inputRef.current?.click()}
1850
+ className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${
1851
+ showPreview
1852
+ ? "grid-cols-1 lg:grid-cols-2"
1853
+ : sidebarCollapsed || !hasSidebarFields
1854
+ ? "grid-cols-1"
1855
+ : "grid-cols-1 lg:grid-cols-[1fr_380px]"
1856
+ }`}
877
1857
  >
878
- <input
879
- ref={inputRef}
880
- type="file"
881
- className="kyro-form-upload-input"
882
- onChange={(e) => {
883
- const file = e.target.files?.[0];
884
- if (file) handleFile(file);
885
- }}
886
- disabled={disabled}
887
- accept={field.mimeTypes?.join(",")}
888
- />
889
- {preview || value?.url ? (
890
- <div className="kyro-form-upload-preview">
891
- <img
892
- src={preview || value.url}
893
- alt="Preview"
894
- className="kyro-form-upload-image"
895
- />
896
- <div className="kyro-form-upload-info">
897
- <span className="kyro-form-upload-filename">
898
- {value?.filename || "Uploaded file"}
1858
+ <div className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
1859
+ {config.tabs ? (
1860
+ renderField({ type: "tabs", tabs: config.tabs } as any)
1861
+ ) : (
1862
+ <div className="surface-tile p-8 space-y-8">
1863
+ {config.fields
1864
+ .filter(
1865
+ (f) => !f.admin?.position || f.admin.position === "main",
1866
+ )
1867
+ .map((f) => renderField(f))}
1868
+ </div>
1869
+ )}
1870
+ </div>
1871
+
1872
+ {showPreview ? (
1873
+ <div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
1874
+ <div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
1875
+ <div className="absolute top-4 left-4 z-10 flex items-center gap-2">
1876
+ <div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
1877
+ <span className="text-[10px] font-black uppercase tracking-widest text-white/60">
1878
+ Live Preview Mode
1879
+ </span>
1880
+ </div>
1881
+ <iframe
1882
+ src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
1883
+ className="w-full h-full border-none"
1884
+ title="Live Preview"
1885
+ />
1886
+ <div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
1887
+ </div>
1888
+ </div>
1889
+ ) : sidebarCollapsed ? null : (
1890
+ <div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
1891
+ {config.fields.some((f) => f.admin?.position === "sidebar") && (
1892
+ <div className="surface-tile p-6 space-y-6">
1893
+ <h3 className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
1894
+ Settings
1895
+ </h3>
1896
+ {config.fields
1897
+ .filter((f) => f.admin?.position === "sidebar")
1898
+ .map((f) => renderField(f))}
1899
+ </div>
1900
+ )}
1901
+ </div>
1902
+ )}
1903
+ </div>
1904
+ );
1905
+ };
1906
+
1907
+ const renderVersionView = () => (
1908
+ <div className="w-full animate-in fade-in slide-in-from-bottom-4 pb-12">
1909
+ <div className="surface-tile p-0 overflow-hidden">
1910
+ <div className="px-6 py-4 border-b border-[var(--kyro-border)] flex items-center justify-between">
1911
+ <div>
1912
+ <h2 className="text-lg font-bold text-[var(--kyro-text-primary)]">
1913
+ Version History
1914
+ </h2>
1915
+ <p className="text-[11px] text-[var(--kyro-text-muted)] mt-0.5">
1916
+ {compareMode
1917
+ ? `Select 2 versions · ${compareSelected.length}/2 chosen`
1918
+ : `${versions.length} snapshot${versions.length !== 1 ? "s" : ""} · Auto-saved`}
1919
+ </p>
1920
+ </div>
1921
+ <div className="flex items-center gap-2">
1922
+ {compareMode && compareSelected.length === 2 && (
1923
+ <button
1924
+ type="button"
1925
+ onClick={handleCompareVersions}
1926
+ disabled={loadingDiffs}
1927
+ className="px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-[11px] font-bold uppercase tracking-wider hover:opacity-90 disabled:opacity-50"
1928
+ >
1929
+ {loadingDiffs ? "Comparing..." : "Compare"}
1930
+ </button>
1931
+ )}
1932
+ <button
1933
+ type="button"
1934
+ onClick={() => {
1935
+ setCompareMode(!compareMode);
1936
+ setCompareSelected([]);
1937
+ setCompareDiffs([]);
1938
+ }}
1939
+ className={`px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all ${
1940
+ compareMode
1941
+ ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
1942
+ : "border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
1943
+ }`}
1944
+ >
1945
+ {compareMode ? "Done" : "Compare"}
1946
+ </button>
1947
+ </div>
1948
+ </div>
1949
+
1950
+ {compareDiffs.length > 0 && (
1951
+ <div className="border-b border-[var(--kyro-border)]">
1952
+ <div className="px-6 py-3 flex items-center justify-between">
1953
+ <span className="text-[11px] font-bold text-[var(--kyro-text-primary)] uppercase tracking-wider">
1954
+ {compareDiffs.length} change
1955
+ {compareDiffs.length !== 1 ? "s" : ""}
899
1956
  </span>
900
1957
  <button
901
1958
  type="button"
902
- className="kyro-form-upload-change"
903
- onClick={(e) => {
904
- e.stopPropagation();
905
- inputRef.current?.click();
906
- }}
1959
+ onClick={() => setCompareDiffs([])}
1960
+ className="p-1 rounded hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
907
1961
  >
908
- Change
1962
+ <svg
1963
+ className="w-3.5 h-3.5"
1964
+ viewBox="0 0 24 24"
1965
+ fill="none"
1966
+ stroke="currentColor"
1967
+ strokeWidth="2.5"
1968
+ >
1969
+ <path d="M18 6L6 18M6 6l12 12" />
1970
+ </svg>
909
1971
  </button>
910
1972
  </div>
1973
+ <div className="max-h-[400px] overflow-y-auto">
1974
+ {compareDiffs.map((d, i) => (
1975
+ <div
1976
+ key={i}
1977
+ className="grid grid-cols-4 gap-3 px-6 py-2.5 text-[11px] font-mono border-t border-[var(--kyro-border)] hover:bg-[var(--kyro-bg-secondary)]"
1978
+ >
1979
+ <div className="text-[var(--kyro-text-muted)] truncate">
1980
+ {d.field}
1981
+ </div>
1982
+ <div className="text-[var(--kyro-text-muted)] truncate">
1983
+ {typeof d.oldValue === "object"
1984
+ ? JSON.stringify(d.oldValue)
1985
+ : String(d.oldValue ?? "null")}
1986
+ </div>
1987
+ <div className="col-span-2 text-[var(--kyro-text-primary)] truncate">
1988
+ {typeof d.newValue === "object"
1989
+ ? JSON.stringify(d.newValue)
1990
+ : String(d.newValue ?? "null")}
1991
+ </div>
1992
+ </div>
1993
+ ))}
1994
+ </div>
1995
+ </div>
1996
+ )}
1997
+
1998
+ {loadingVersions ? (
1999
+ <div className="flex justify-center py-16">
2000
+ <span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
2001
+ </div>
2002
+ ) : versions.length === 0 ? (
2003
+ <div className="text-center py-16 text-[var(--kyro-text-muted)] text-sm italic">
2004
+ No versions yet.
911
2005
  </div>
912
2006
  ) : (
913
- <div className="kyro-form-upload-placeholder">
914
- <svg
915
- width="32"
916
- height="32"
917
- viewBox="0 0 24 24"
918
- fill="none"
919
- stroke="currentColor"
920
- strokeWidth="1.5"
921
- >
922
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
923
- <polyline points="17,8 12,3 7,8" />
924
- <line x1="12" y1="3" x2="12" y2="15" />
925
- </svg>
926
- <span>Drop image here or click to upload</span>
927
- <span className="kyro-form-upload-hint">
928
- PNG, JPG, GIF up to 10MB
929
- </span>
2007
+ <div className="divide-y divide-[var(--kyro-border)]">
2008
+ {versions.map((v, i) => {
2009
+ const isSelected = compareSelected.includes(v.id);
2010
+ const isDraftVersion = v.status === "draft";
2011
+ const isAutoSaved = (v.changeDescription || "")
2012
+ .toLowerCase()
2013
+ .includes("auto");
2014
+
2015
+ return (
2016
+ <div
2017
+ key={v.id}
2018
+ onClick={
2019
+ compareMode ? () => toggleCompareSelection(v.id) : undefined
2020
+ }
2021
+ className={`grid grid-cols-12 gap-3 px-6 py-3 items-center transition-all ${
2022
+ compareMode
2023
+ ? isSelected
2024
+ ? "bg-[var(--kyro-primary)]/5 cursor-pointer"
2025
+ : "hover:bg-[var(--kyro-bg-secondary)] cursor-pointer"
2026
+ : "hover:bg-[var(--kyro-bg-secondary)]"
2027
+ } ${isDraftVersion ? "" : ""}`}
2028
+ >
2029
+ <div className="col-span-1 flex items-center gap-2">
2030
+ {compareMode ? (
2031
+ <div
2032
+ className={`w-4 h-4 rounded-full border ${
2033
+ isSelected
2034
+ ? "border-[var(--kyro-primary)] bg-[var(--kyro-primary)]"
2035
+ : "border-[var(--kyro-border)]"
2036
+ }`}
2037
+ >
2038
+ {isSelected && (
2039
+ <svg
2040
+ className="w-full h-full text-white p-0.5"
2041
+ viewBox="0 0 24 24"
2042
+ fill="none"
2043
+ stroke="currentColor"
2044
+ strokeWidth="3"
2045
+ >
2046
+ <path d="M20 6L9 17l-5-5" />
2047
+ </svg>
2048
+ )}
2049
+ </div>
2050
+ ) : (
2051
+ <span className="text-[10px] font-bold text-[var(--kyro-text-muted)] w-5">
2052
+ {versions.length - i}
2053
+ </span>
2054
+ )}
2055
+ </div>
2056
+ <div className="col-span-4 min-w-0">
2057
+ <div className="text-[13px] font-medium text-[var(--kyro-text-primary)] truncate flex items-center gap-2">
2058
+ {v.changeDescription || "Snapshot"}
2059
+ {isAutoSaved && (
2060
+ <span className="text-[9px] px-1.5 py-0.5 bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] rounded font-bold uppercase tracking-wider">
2061
+ Auto
2062
+ </span>
2063
+ )}
2064
+ </div>
2065
+ <div className="text-[11px] text-[var(--kyro-text-muted)]">
2066
+ {new Date(v.createdAt).toLocaleString("en-US", {
2067
+ month: "short",
2068
+ day: "numeric",
2069
+ hour: "2-digit",
2070
+ minute: "2-digit",
2071
+ })}
2072
+ </div>
2073
+ </div>
2074
+ <div className="col-span-3">
2075
+ {v.status && (
2076
+ <span
2077
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold capitalize tracking-wider ${
2078
+ v.status === "published"
2079
+ ? " text-[var(--kyro-success)]"
2080
+ : " text-[var(--kyro-warning)]"
2081
+ }`}
2082
+ >
2083
+ <span
2084
+ className={`w-1.5 h-1.5 rounded-full ${v.status === "published" ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-warning)]"}`}
2085
+ />
2086
+ {v.status}
2087
+ </span>
2088
+ )}
2089
+ </div>
2090
+ <div className="col-span-2 text-[11px] text-[var(--kyro-text-muted)]">
2091
+ {v.createdBy || "system"}
2092
+ </div>
2093
+ <div className="col-span-2 flex justify-end">
2094
+ {!compareMode && (
2095
+ <button
2096
+ type="button"
2097
+ onClick={() => handleRestoreVersion(v.id)}
2098
+ className="px-3 py-1.5 rounded-lg border border-[var(--kyro-border)] text-[11px] font-bold uppercase tracking-wider text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95"
2099
+ >
2100
+ Restore
2101
+ </button>
2102
+ )}
2103
+ </div>
2104
+ </div>
2105
+ );
2106
+ })}
930
2107
  </div>
931
2108
  )}
932
2109
  </div>
933
- {field.admin?.description && !error && (
934
- <p className="kyro-form-help">{field.admin.description}</p>
935
- )}
936
- {error && <p className="kyro-form-error">{error}</p>}
2110
+ </div>
2111
+ );
2112
+
2113
+ const renderApiView = () => (
2114
+ <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
2115
+ <div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
2116
+ <div className="surface-tile p-8 min-w-0">
2117
+ <h2 className="text-xl font-black mb-6">Response Payload</h2>
2118
+ <div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
2119
+ <pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
2120
+ {JSON.stringify(formData, null, 2)}
2121
+ </pre>
2122
+ </div>
2123
+ </div>
2124
+
2125
+ <div className="space-y-6">
2126
+ <div className="surface-tile p-8 space-y-6">
2127
+ <h2 className="text-xl font-black mb-6">API Info</h2>
2128
+
2129
+ <div className="space-y-6">
2130
+ <div>
2131
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
2132
+ Reference Path
2133
+ </label>
2134
+ <div className="relative group">
2135
+ <code className="block bg-[var(--kyro-bg-secondary)] p-4 rounded-xl border border-[var(--kyro-border)] text-[var(--kyro-text-primary)] text-xs font-mono break-all leading-relaxed">
2136
+ {`/api/${collectionSlug}/${formData.id || ""}`}
2137
+ </code>
2138
+ </div>
2139
+ </div>
2140
+
2141
+ <div>
2142
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-3">
2143
+ Methods Allowed
2144
+ </label>
2145
+ <div className="flex gap-2">
2146
+ <span className="px-3 py-1.5 bg-green-500/10 text-green-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
2147
+ GET
2148
+ </span>
2149
+ <span className="px-3 py-1.5 bg-amber-500/10 text-amber-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
2150
+ PATCH
2151
+ </span>
2152
+ <span className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
2153
+ DELETE
2154
+ </span>
2155
+ </div>
2156
+ </div>
2157
+
2158
+ <div className="pt-6 border-t border-[var(--kyro-border)]">
2159
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-4">
2160
+ Security Policy
2161
+ </label>
2162
+ <div className="space-y-3">
2163
+ {[
2164
+ {
2165
+ id: "auth-required",
2166
+ label: "Authorization required",
2167
+ checked: true,
2168
+ },
2169
+ {
2170
+ id: "auth-admin",
2171
+ label: "System administrator only",
2172
+ checked: false,
2173
+ },
2174
+ {
2175
+ id: "auth-api",
2176
+ label: "API Key authentication allowed",
2177
+ checked: true,
2178
+ },
2179
+ ].map((item) => (
2180
+ <label
2181
+ key={item.id}
2182
+ className="flex items-center gap-3 cursor-pointer group"
2183
+ >
2184
+ <div
2185
+ className={`w-4 h-4 rounded border transition-all flex items-center justify-center ${item.checked ? "bg-[var(--kyro-primary)] border-[var(--kyro-primary)]" : "border-[var(--kyro-border)] group-hover:border-[var(--kyro-text-secondary)]"}`}
2186
+ >
2187
+ {item.checked && (
2188
+ <svg
2189
+ width="10"
2190
+ height="10"
2191
+ viewBox="0 0 24 24"
2192
+ fill="none"
2193
+ stroke="white"
2194
+ strokeWidth="4"
2195
+ >
2196
+ <path d="M20 6L9 17l-5-5" />
2197
+ </svg>
2198
+ )}
2199
+ </div>
2200
+ <span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
2201
+ {item.label}
2202
+ </span>
2203
+ </label>
2204
+ ))}
2205
+ </div>
2206
+ </div>
2207
+
2208
+ <div className="pt-6 border-t border-[var(--kyro-border)]">
2209
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
2210
+ Usage Help
2211
+ </label>
2212
+ <p className="text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">
2213
+ Include the{" "}
2214
+ <code className="text-[var(--kyro-text-primary)] font-bold">
2215
+ Authorization: Bearer &lt;token&gt;
2216
+ </code>{" "}
2217
+ header to perform write operations on this document.
2218
+ </p>
2219
+ </div>
2220
+ </div>
2221
+ </div>
2222
+ </div>
2223
+ </div>
2224
+ </div>
2225
+ );
2226
+
2227
+ return (
2228
+ <div className="flex flex-col h-full">
2229
+ {layout !== "single" && renderHeader()}
2230
+ <main className="w-full">
2231
+ {view === "edit" && renderEditView()}
2232
+ {view === "version" && renderVersionView()}
2233
+ {view === "api" && renderApiView()}
2234
+ </main>
2235
+ <ConfirmModal
2236
+ open={confirmModal.open}
2237
+ onClose={() => setConfirmModal({ ...confirmModal, open: false })}
2238
+ onConfirm={() => {
2239
+ confirmModal.onConfirm();
2240
+ setConfirmModal({ ...confirmModal, open: false });
2241
+ }}
2242
+ title={confirmModal.title}
2243
+ message={confirmModal.message}
2244
+ variant={confirmModal.danger ? "danger" : "default"}
2245
+ />
2246
+ <UIModal
2247
+ open={alertModal.open}
2248
+ onClose={() => setAlertModal({ ...alertModal, open: false })}
2249
+ title={alertModal.title}
2250
+ size="sm"
2251
+ footer={
2252
+ <button
2253
+ type="button"
2254
+ onClick={() => setAlertModal({ ...alertModal, open: false })}
2255
+ className="px-4 py-2 rounded-lg font-medium text-sm bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] hover:opacity-90 transition-colors"
2256
+ >
2257
+ OK
2258
+ </button>
2259
+ }
2260
+ >
2261
+ <p className="text-[var(--kyro-text-secondary)]">
2262
+ {alertModal.message}
2263
+ </p>
2264
+ </UIModal>
937
2265
  </div>
938
2266
  );
939
2267
  }
@@ -963,33 +2291,33 @@ function RelationshipField({
963
2291
  ? field.relationTo[0]
964
2292
  : field.relationTo;
965
2293
 
966
- useEffect(() => {
967
- if (isOpen) {
968
- setLoading(true);
969
- fetch(`/api/${targetCollection}?limit=50`)
970
- .then((res) => res.json())
971
- .then((data) => {
972
- setOptions(data.docs || []);
973
- setLoading(false);
974
- })
975
- .catch((err) => {
976
- console.error("Failed to fetch relations:", err);
977
- setLoading(false);
978
- });
979
- }
980
- }, [isOpen, targetCollection]);
2294
+ const fetchOptions = () => {
2295
+ setLoading(true);
2296
+ fetch(`/api/${targetCollection}?limit=50`)
2297
+ .then((res) => res.json())
2298
+ .then((data) => {
2299
+ setOptions(data.docs || []);
2300
+ setLoading(false);
2301
+ })
2302
+ .catch((err) => {
2303
+ console.error("Failed to fetch relations:", err);
2304
+ setLoading(false);
2305
+ });
2306
+ };
981
2307
 
982
- const filteredOptions = options.filter((opt) => {
983
- const term = search.toLowerCase();
984
- const searchableFields = ["title", "name", "filename", "id", "slug"];
985
- return searchableFields.some(
986
- (key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
987
- );
988
- });
2308
+ useEffect(() => {
2309
+ fetchOptions();
2310
+ }, [targetCollection]);
989
2311
 
990
2312
  const getLabel = (opt: any) => {
991
2313
  if (!opt) return "";
992
- return opt.title || opt.name || opt.filename || opt.slug || opt.id;
2314
+ return (
2315
+ opt.title || opt.name || opt.label || opt.filename || opt.slug || opt.id
2316
+ );
2317
+ };
2318
+
2319
+ const findOptionById = (id: string) => {
2320
+ return options.find((opt) => opt.id === id);
993
2321
  };
994
2322
 
995
2323
  const isSelected = (optId: string) => {
@@ -1018,6 +2346,33 @@ function RelationshipField({
1018
2346
  }
1019
2347
  };
1020
2348
 
2349
+ const renderSelectedValue = () => {
2350
+ if (!value) return null;
2351
+ if (isMultiple && Array.isArray(value)) {
2352
+ if (value.length === 0) return "None selected";
2353
+ return value
2354
+ .map((v) => {
2355
+ const id = v.id || v;
2356
+ const opt = findOptionById(id);
2357
+ return opt ? getLabel(opt) : id;
2358
+ })
2359
+ .join(", ");
2360
+ }
2361
+ const id = value.id || value;
2362
+ const opt = findOptionById(id);
2363
+ return opt ? getLabel(opt) : id;
2364
+ };
2365
+
2366
+ const filteredOptions = search
2367
+ ? (options || []).filter((opt) => {
2368
+ const term = search.toLowerCase();
2369
+ const searchableFields = ["title", "name", "label", "filename", "slug"];
2370
+ return searchableFields.some(
2371
+ (key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
2372
+ );
2373
+ })
2374
+ : options || [];
2375
+
1021
2376
  return (
1022
2377
  <div className="kyro-form-field">
1023
2378
  <label className="kyro-form-label">
@@ -1041,15 +2396,7 @@ function RelationshipField({
1041
2396
 
1042
2397
  <div className="kyro-form-relationship-value">
1043
2398
  {value ? (
1044
- isMultiple && Array.isArray(value) ? (
1045
- value.length > 0 ? (
1046
- `${value.length} items selected`
1047
- ) : (
1048
- "None selected"
1049
- )
1050
- ) : (
1051
- `Selected: ${getLabel(value) || value.id || value}`
1052
- )
2399
+ renderSelectedValue()
1053
2400
  ) : (
1054
2401
  <span className="kyro-form-relationship-empty">
1055
2402
  Click to search and select...
@@ -1099,7 +2446,7 @@ function RelationshipField({
1099
2446
  >
1100
2447
  <span>{getLabel(opt)}</span>
1101
2448
  <span className="kyro-relation-modal-item-id">
1102
- ({opt.id.slice(0, 8)}...)
2449
+ {opt.id ? `(${String(opt.id).slice(0, 8)}...)` : ""}
1103
2450
  </span>
1104
2451
  </button>
1105
2452
  ))
@@ -1121,3 +2468,41 @@ function RelationshipField({
1121
2468
  </div>
1122
2469
  );
1123
2470
  }
2471
+
2472
+ // SEO Utilities
2473
+ function stripHtml(html: string) {
2474
+ if (typeof html !== "string") return "";
2475
+ return html.replace(/<[^>]*>?/gm, "").trim();
2476
+ }
2477
+
2478
+ const SeoPreview = ({
2479
+ title,
2480
+ description,
2481
+ slug,
2482
+ }: {
2483
+ title: string;
2484
+ description: string;
2485
+ slug: string;
2486
+ }) => (
2487
+ <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
2488
+ <div className="flex items-center gap-2 mb-2">
2489
+ <div className="w-7 h-7 bg-[var(--kyro-bg-secondary)] rounded-full flex items-center justify-center text-[10px] text-[var(--kyro-text-primary)] font-medium border border-[var(--kyro-border)]">
2490
+ K
2491
+ </div>
2492
+ <div className="flex flex-col">
2493
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
2494
+ kyro-cms.com
2495
+ </span>
2496
+ <span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
2497
+ https://kyro-cms.com › posts › {slug}
2498
+ </span>
2499
+ </div>
2500
+ </div>
2501
+ <h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
2502
+ {title}
2503
+ </h3>
2504
+ <p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
2505
+ {description}
2506
+ </p>
2507
+ </div>
2508
+ );