@kyro-cms/admin 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +149 -51
  2. package/package.json +53 -6
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +136 -27
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +1417 -661
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +3 -3
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +199 -57
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +786 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +191 -53
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +149 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/UserManagement.tsx +204 -0
  26. package/src/components/VersionHistoryPanel.tsx +3 -3
  27. package/src/components/WebhookManager.tsx +608 -0
  28. package/src/components/blocks/AccordionBlock.tsx +97 -0
  29. package/src/components/blocks/ArrayBlock.tsx +75 -0
  30. package/src/components/blocks/BlockEditModal.MARKER +12 -0
  31. package/src/components/blocks/BlockEditModal.tsx +774 -0
  32. package/src/components/blocks/ButtonBlock.tsx +165 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +66 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +151 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +64 -0
  38. package/src/components/blocks/HeadingBlock.tsx +81 -0
  39. package/src/components/blocks/HeroBlock.tsx +157 -0
  40. package/src/components/blocks/ImageBlock.tsx +83 -0
  41. package/src/components/blocks/LinkBlock.tsx +71 -0
  42. package/src/components/blocks/ListBlock.tsx +39 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +279 -0
  45. package/src/components/blocks/VStackBlock.tsx +75 -0
  46. package/src/components/blocks/VideoBlock.tsx +45 -0
  47. package/src/components/blocks/index.ts +10 -0
  48. package/src/components/fields/BlocksField.tsx +323 -0
  49. package/src/components/fields/CheckboxField.tsx +15 -9
  50. package/src/components/fields/CodeField.tsx +234 -0
  51. package/src/components/fields/DateField.tsx +38 -11
  52. package/src/components/fields/EditorClient.tsx +271 -0
  53. package/src/components/fields/FileField.tsx +390 -0
  54. package/src/components/fields/HybridContentField.tsx +109 -0
  55. package/src/components/fields/ImageField.tsx +429 -0
  56. package/src/components/fields/JSONField.tsx +361 -0
  57. package/src/components/fields/MarkdownField.tsx +282 -0
  58. package/src/components/fields/NumberField.tsx +42 -12
  59. package/src/components/fields/PortableTextField.tsx +143 -0
  60. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  61. package/src/components/fields/RelationshipField.tsx +231 -59
  62. package/src/components/fields/SelectField.tsx +25 -15
  63. package/src/components/fields/TextField.tsx +45 -14
  64. package/src/components/fields/extensions/blockComponents.tsx +237 -0
  65. package/src/components/fields/extensions/blocksStore.ts +273 -0
  66. package/src/components/fields/index.ts +13 -0
  67. package/src/components/index.ts +1 -2
  68. package/src/components/layout/Header.tsx +2 -2
  69. package/src/components/layout/Layout.tsx +2 -2
  70. package/src/components/ui/Badge.tsx +9 -4
  71. package/src/components/ui/BlockDrawer.tsx +79 -0
  72. package/src/components/ui/Button.tsx +1 -1
  73. package/src/components/ui/CommandPalette.tsx +362 -0
  74. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  75. package/src/components/ui/Dropdown.tsx +1 -1
  76. package/src/components/ui/Modal.tsx +37 -12
  77. package/src/components/ui/PromptModal.tsx +94 -0
  78. package/src/components/ui/SlidePanel.tsx +43 -16
  79. package/src/components/ui/Toast.tsx +80 -14
  80. package/src/env.d.ts +16 -0
  81. package/src/env.ts +20 -0
  82. package/src/index.ts +0 -1
  83. package/src/layouts/AdminLayout.astro +164 -170
  84. package/src/layouts/AuthLayout.astro +23 -6
  85. package/src/lib/MediaService.ts +541 -0
  86. package/src/lib/auth/sqlite-adapter.ts +319 -0
  87. package/src/lib/config.ts +22 -6
  88. package/src/lib/dataStore.ts +132 -74
  89. package/src/lib/db/adapter.ts +54 -0
  90. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  91. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  92. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  93. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  94. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  95. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  96. package/src/lib/db/index.ts +449 -0
  97. package/src/lib/db/mongodb-adapter.ts +207 -0
  98. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  99. package/src/lib/db/schema/mysql-auth.ts +113 -0
  100. package/src/lib/db/schema/mysql-content.ts +20 -0
  101. package/src/lib/db/schema/postgres-auth.ts +116 -0
  102. package/src/lib/db/schema/postgres-content.ts +35 -0
  103. package/src/lib/db/schema/postgres-media.ts +52 -0
  104. package/src/lib/db/schema/postgres-settings.ts +11 -0
  105. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  106. package/src/lib/db/schema/sqlite-content.ts +20 -0
  107. package/src/lib/graphql/index.ts +1 -0
  108. package/src/lib/graphql/schema.ts +443 -0
  109. package/src/lib/rate-limit.ts +267 -0
  110. package/src/lib/storage.ts +374 -0
  111. package/src/lib/store.ts +85 -0
  112. package/src/middleware.ts +70 -11
  113. package/src/pages/[collection]/[id].astro +178 -122
  114. package/src/pages/[collection]/index.astro +24 -156
  115. package/src/pages/admin/api-explorer.astro +98 -0
  116. package/src/pages/admin/graphql-explorer.astro +40 -0
  117. package/src/pages/admin/graphql.astro +97 -0
  118. package/src/pages/admin/index.astro +200 -139
  119. package/src/pages/admin/keys.astro +8 -0
  120. package/src/pages/admin/rest-playground.astro +44 -0
  121. package/src/pages/admin/webhooks.astro +8 -0
  122. package/src/pages/api/[collection]/[id]/publish.ts +44 -0
  123. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  124. package/src/pages/api/[collection]/[id]/versions.ts +36 -0
  125. package/src/pages/api/[collection]/[id].ts +102 -159
  126. package/src/pages/api/[collection]/index.ts +151 -230
  127. package/src/pages/api/auth/[id].ts +48 -69
  128. package/src/pages/api/auth/audit-logs.ts +20 -43
  129. package/src/pages/api/auth/login.ts +159 -45
  130. package/src/pages/api/auth/logout.ts +42 -24
  131. package/src/pages/api/auth/refresh.ts +119 -0
  132. package/src/pages/api/auth/register.ts +110 -40
  133. package/src/pages/api/auth/users.ts +22 -97
  134. package/src/pages/api/collections.ts +59 -0
  135. package/src/pages/api/globals/[slug]/test.ts +172 -0
  136. package/src/pages/api/globals/[slug].ts +42 -0
  137. package/src/pages/api/graphql.ts +90 -0
  138. package/src/pages/api/health.ts +417 -40
  139. package/src/pages/api/keys/[id].ts +26 -0
  140. package/src/pages/api/keys/index.ts +75 -0
  141. package/src/pages/api/media/[id].ts +309 -0
  142. package/src/pages/api/media/folders.ts +609 -0
  143. package/src/pages/api/media/index.ts +146 -0
  144. package/src/pages/api/media/resize.ts +267 -0
  145. package/src/pages/api/search.ts +82 -0
  146. package/src/pages/api/slug-availability.ts +70 -0
  147. package/src/pages/api/storage-config.ts +20 -0
  148. package/src/pages/api/storage-status.ts +206 -0
  149. package/src/pages/api/upload.ts +334 -0
  150. package/src/pages/api/webhooks/index.ts +71 -0
  151. package/src/pages/audit/index.astro +2 -104
  152. package/src/pages/login.astro +11 -11
  153. package/src/pages/media.astro +10 -0
  154. package/src/pages/preview/[collection]/[id].astro +178 -0
  155. package/src/pages/register.astro +13 -13
  156. package/src/pages/roles/index.astro +21 -21
  157. package/src/pages/settings/[slug].astro +162 -0
  158. package/src/pages/settings/index.astro +9 -0
  159. package/src/pages/users/[id].astro +29 -21
  160. package/src/pages/users/index.astro +22 -17
  161. package/src/pages/users/new.astro +18 -17
  162. package/src/styles/main.css +553 -128
  163. package/src/components/layout/Sidebar.tsx +0 -497
@@ -1,144 +1,656 @@
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";
8
+ import { ImageField } from "./fields/ImageField";
9
+ import { PortableTextField, HybridContentField } 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 CodeMirror from "@uiw/react-codemirror";
16
+ import { json } from "@codemirror/lang-json";
17
+ import { javascript } from "@codemirror/lang-javascript";
18
+ import { aura } from "@uiw/codemirror-theme-aura";
19
+ import { globals, collections } from "@/lib/config";
20
+
21
+ import { BlocksField } from "./fields/BlocksField";
22
+ // Lightweight slugify helper for on-the-fly slug generation from a title/name
23
+ function slugifyText(text: string): string {
24
+ if (!text) return "";
25
+ return text
26
+ .toString()
27
+ .toLowerCase()
28
+ .trim()
29
+ .replace(/\s+/g, "-") // Replace spaces with -
30
+ .replace(/[^\w-]+/g, "") // Remove all non-word chars
31
+ .replace(/--+/g, "-") // Replace multiple - with single -
32
+ .replace(/^-+/, "") // Trim - from start of text
33
+ .replace(/-+$/, ""); // Trim - from end of text
34
+ }
3
35
 
4
36
  interface AutoFormProps {
5
- config: CollectionConfig;
37
+ config: CollectionConfig | GlobalConfig;
6
38
  data?: Record<string, any>;
7
39
  errors?: Record<string, string>;
8
40
  onChange?: (data: Record<string, any>) => void;
9
41
  disabled?: boolean;
10
42
  collectionSlug?: string;
43
+ globalSlug?: string;
44
+ documentName?: string;
45
+ layout?: "split" | "single";
46
+ onActionSuccess?: (message: string) => void;
47
+ onActionError?: (message: string) => void;
48
+ documentStatus?: "draft" | "published";
49
+ justSaved?: boolean;
11
50
  }
12
51
 
13
52
  export function AutoForm({
14
- config,
15
- data = {},
53
+ config: propConfig,
54
+ data: initialData = {},
16
55
  errors = {},
17
56
  onChange,
18
- disabled,
57
+ disabled: propDisabled,
58
+ collectionSlug,
59
+ globalSlug,
60
+ documentName,
61
+ layout = "split",
62
+ onActionSuccess,
63
+ onActionError,
64
+ documentStatus,
65
+ justSaved,
19
66
  }: AutoFormProps) {
67
+ // Resolve the "live" config to preserve functions (admin.condition) lost during prop serialization
68
+ const activeConfig = globalSlug
69
+ ? globals[globalSlug]
70
+ : collectionSlug
71
+ ? collections[collectionSlug]
72
+ : propConfig;
73
+ const config = activeConfig || propConfig;
74
+
75
+ // Helper to extract default values from config recursively
76
+ function getDefaults(fields: any[], prefix = ""): Record<string, any> {
77
+ const defaults: Record<string, any> = {};
78
+ for (const field of fields || []) {
79
+ if (field.defaultValue !== undefined) {
80
+ const key = prefix + field.name;
81
+ defaults[key] = field.defaultValue;
82
+ // Also set nested defaults for groups
83
+ if (field.type === "group" && field.fields) {
84
+ for (const subField of field.fields) {
85
+ if (subField.defaultValue !== undefined) {
86
+ defaults[prefix + field.name + "." + subField.name] =
87
+ subField.defaultValue;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ if (field.fields && Array.isArray(field.fields)) {
93
+ Object.assign(defaults, getDefaults(field.fields, field.name + "."));
94
+ }
95
+ if (field.tabs) {
96
+ for (const tab of field.tabs) {
97
+ if (tab.fields) {
98
+ Object.assign(defaults, getDefaults(tab.fields, prefix));
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return defaults;
104
+ }
105
+
106
+ // Helper to flatten nested object with dot notation keys
107
+ function flattenObject(
108
+ obj: Record<string, any>,
109
+ prefix = "",
110
+ ): Record<string, any> {
111
+ const result: Record<string, any> = {};
112
+ for (const key in obj) {
113
+ const newKey = prefix ? `${prefix}.${key}` : key;
114
+ const val = obj[key];
115
+ if (
116
+ val !== null &&
117
+ typeof val === "object" &&
118
+ !Array.isArray(val) &&
119
+ // Only recurse into plain objects, not Dates, Maps, or other class instances
120
+ (val.constructor === Object || !val.constructor)
121
+ ) {
122
+ Object.assign(result, flattenObject(val, newKey));
123
+ } else {
124
+ result[newKey] = val;
125
+ }
126
+ }
127
+ return result;
128
+ }
129
+
130
+ // Helper to unflatten dot notation keys back to nested object
131
+ function unflattenObject(flat: Record<string, any>): Record<string, any> {
132
+ const result: Record<string, any> = {};
133
+ for (const key in flat) {
134
+ const parts = key.split(".");
135
+ let current = result;
136
+ for (let i = 0; i < parts.length - 1; i++) {
137
+ if (!current[parts[i]]) {
138
+ current[parts[i]] = {};
139
+ }
140
+ current = current[parts[i]];
141
+ }
142
+ current[parts[parts.length - 1]] = flat[key];
143
+ }
144
+ return result;
145
+ }
146
+
147
+ // Merge initial data with defaults from config
148
+ const [formData, setFormData] = useState<Record<string, any>>({});
149
+
150
+ useEffect(() => {
151
+ try {
152
+ const configDefaults = config ? getDefaults(config.fields) : {};
153
+ const flatInitialData = flattenObject(initialData || {});
154
+ const mergedFlatData = { ...configDefaults, ...flatInitialData };
155
+ const mergedInitialData = unflattenObject(mergedFlatData);
156
+ setFormData(mergedInitialData);
157
+ } catch (e) {
158
+ console.error("Critical error in AutoForm data initialization:", e);
159
+ // Fallback to raw initialData if flattening fails
160
+ setFormData(initialData || {});
161
+ }
162
+ }, [initialData, config]);
163
+ const [activeTab, setActiveTab] = useState(0);
164
+ const [isSlugLocked, setIsSlugLocked] = useState(true);
165
+ const [view, setView] = useState<"edit" | "version" | "api">("edit");
166
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
167
+ const [versions, setVersions] = useState<any[]>([]);
168
+ const [loadingVersions, setLoadingVersions] = useState(false);
169
+ const [showPreview, setShowPreview] = useState(false);
170
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
171
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
172
+ const [loadingFields, setLoadingFields] = useState<Record<string, boolean>>(
173
+ {},
174
+ );
175
+
176
+ const disabled = propDisabled;
177
+
178
+ // Track unsaved changes
179
+ useEffect(() => {
180
+ const isDifferent =
181
+ JSON.stringify(formData) !== JSON.stringify(initialData);
182
+ setHasUnsavedChanges(isDifferent);
183
+ }, [formData, initialData]);
184
+
185
+ // Auto-generate slug from title if locked
186
+ useEffect(() => {
187
+ if (isSlugLocked && formData.title) {
188
+ const newSlug = slugifyText(formData.title);
189
+ if (newSlug !== formData.slug) {
190
+ setFormData((prev) => ({ ...prev, slug: newSlug }));
191
+ }
192
+ }
193
+ }, [formData.title, isSlugLocked]);
194
+
195
+ // Sync prop changes to local state
196
+ useEffect(() => {
197
+ if (initialData && Object.keys(initialData).length > 0) {
198
+ setFormData(initialData);
199
+ }
200
+ }, [initialData]);
201
+
202
+ // Sync to hidden input for Astro form submission
203
+ useEffect(() => {
204
+ const hiddenInput = document.getElementById(
205
+ "form-data",
206
+ ) as HTMLInputElement;
207
+ if (hiddenInput) {
208
+ hiddenInput.value = JSON.stringify(formData);
209
+ }
210
+ onChange?.(formData);
211
+ }, [formData, onChange]);
212
+
213
+ useEffect(() => {
214
+ if (formData.id) fetchVersions();
215
+ }, [formData.id]);
216
+
217
+ const fetchVersions = async () => {
218
+ setLoadingVersions(true);
219
+ try {
220
+ const resp = await fetch(
221
+ `/api/${collectionSlug}/${formData.id}/versions`,
222
+ );
223
+ const data = await resp.json();
224
+ setVersions(data.docs || []);
225
+ } catch (e) {
226
+ console.error("Failed to fetch versions:", e);
227
+ } finally {
228
+ setLoadingVersions(false);
229
+ }
230
+ };
231
+
232
+ const handleRestoreVersion = async (versionId: string) => {
233
+ if (
234
+ !confirm(
235
+ "Are you sure you want to restore this version? This will overwrite your current changes.",
236
+ )
237
+ )
238
+ return;
239
+ try {
240
+ const resp = await fetch(
241
+ `/api/${collectionSlug}/${formData.id}/versions`,
242
+ {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json" },
245
+ body: JSON.stringify({ versionId, action: "restore" }),
246
+ },
247
+ );
248
+ const result = await resp.json();
249
+ if (result.data) {
250
+ setFormData(result.data);
251
+ setView("edit");
252
+ fetchVersions();
253
+ }
254
+ } catch (e) {
255
+ console.error("Restore failed:", e);
256
+ }
257
+ };
258
+
259
+ useEffect(() => {
260
+ const handleShortcuts = (e: KeyboardEvent) => {
261
+ // Cmd/Ctrl + S = Publish
262
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
263
+ e.preventDefault();
264
+ (document.getElementById("btn-save") as any)?.click();
265
+ }
266
+ // Cmd/Ctrl + P = Toggle Preview
267
+ if ((e.metaKey || e.ctrlKey) && e.key === "p") {
268
+ e.preventDefault();
269
+ setShowPreview((prev) => !prev);
270
+ }
271
+ // Keys 1, 2, 3 = Tab Switching
272
+ if (
273
+ document.activeElement?.tagName !== "INPUT" &&
274
+ document.activeElement?.tagName !== "TEXTAREA"
275
+ ) {
276
+ if (e.key === "1") setView("edit");
277
+ if (e.key === "2") setView("version");
278
+ if (e.key === "3") setView("api");
279
+ }
280
+ };
281
+ window.addEventListener("keydown", handleShortcuts);
282
+ return () => window.removeEventListener("keydown", handleShortcuts);
283
+ }, []);
284
+
20
285
  const handleFieldChange = (fieldName: string, value: any) => {
21
- onChange?.({
22
- ...data,
286
+ setFormData((prev) => ({
287
+ ...prev,
23
288
  [fieldName]: value,
24
- });
289
+ }));
25
290
  };
26
291
 
27
- const renderField = (field: Field, parentData?: Record<string, any>) => {
292
+ const renderField = (
293
+ field: Field,
294
+ parentData?: Record<string, any>,
295
+ onParentChange?: (val: any) => void,
296
+ ): React.ReactNode => {
28
297
  if (field.admin?.hidden) return null;
29
298
 
30
- const value =
31
- parentData !== undefined ? parentData[field.name!] : data[field.name!];
299
+ const currentData = parentData !== undefined ? parentData : formData;
300
+
301
+ // Evaluate display condition if present
302
+ // For conditional fields, pass formData as the root context (first arg)
303
+ // and currentData as the sibling context (second arg)
304
+ if (field.admin?.condition && typeof field.admin.condition === "function") {
305
+ try {
306
+ const shouldShow = field.admin.condition(formData, currentData);
307
+ if (!shouldShow) {
308
+ return null;
309
+ }
310
+ } catch (e) {
311
+ console.warn(`Condition error for field ${field.name}:`, e);
312
+ // Show the field if there's an error evaluating the condition
313
+ }
314
+ }
315
+
316
+ const value = currentData[field.name!];
32
317
  const error = errors[field.name!];
33
318
 
319
+ const onFieldChange = (val: any) => {
320
+ if (onParentChange) {
321
+ onParentChange({ ...currentData, [field.name!]: val });
322
+ } else {
323
+ handleFieldChange(field.name!, val);
324
+ }
325
+ };
326
+
34
327
  if (field.type === "row" && "fields" in field) {
35
328
  return (
36
329
  <div
37
330
  key={field.name || `row-${Math.random()}`}
38
- className="kyro-form-row"
331
+ className="kyro-form-row flex gap-6 items-end"
39
332
  >
40
- {(field as any).fields.map((f: Field) => renderField(f, parentData))}
41
- </div>
42
- );
43
- }
333
+ {(field as any).fields.map((f: Field) => {
334
+ const fAdmin = f.admin;
335
+ const actionUrl = fAdmin?.action;
44
336
 
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
- }
337
+ if (f.type === "button" && actionUrl) {
338
+ const siblingEmailField = (field as any).fields?.find(
339
+ (ff: Field) => ff.type === "email",
340
+ );
341
+ return (
342
+ <div key={f.name} className="flex-shrink-0">
343
+ <button
344
+ type="button"
345
+ disabled={disabled}
346
+ onClick={async () => {
347
+ const rowName = field.name;
348
+ const emailFieldName = siblingEmailField?.name;
349
+ let emailValue = formData[emailFieldName];
350
+ if (!emailValue && rowName) {
351
+ emailValue = formData[rowName]?.[emailFieldName];
352
+ }
353
+ if (!emailValue) return;
72
354
 
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))}
355
+ setLoadingFields((prev) => ({
356
+ ...prev,
357
+ [f.name!]: true,
358
+ }));
359
+ try {
360
+ const response = await fetch(actionUrl, {
361
+ method: fAdmin.method || "POST",
362
+ headers: { "Content-Type": "application/json" },
363
+ body: JSON.stringify({ email: emailValue }),
364
+ });
365
+ let result;
366
+ try {
367
+ result = await response.json();
368
+ } catch {
369
+ result = {};
370
+ }
371
+ if (response.ok && result.success) {
372
+ onActionSuccess?.(
373
+ result.message || "Action completed successfully",
374
+ );
375
+ } else {
376
+ const errorMsg =
377
+ result.error ||
378
+ `Request failed (${response.status})`;
379
+ onActionError?.(errorMsg);
380
+ }
381
+ } catch (err: any) {
382
+ onActionError?.(
383
+ err.message || "Error connecting to server",
384
+ );
385
+ } finally {
386
+ setLoadingFields((prev) => ({
387
+ ...prev,
388
+ [f.name!]: false,
389
+ }));
390
+ }
391
+ }}
392
+ disabled={loadingFields[f.name!] || disabled}
393
+ className="bg-[var(--kyro-primary)] text-white px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
394
+ >
395
+ {loadingFields[f.name!] ? "Sending..." : f.label || "Click"}
396
+ </button>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ return (
402
+ <div
403
+ key={f.name}
404
+ className={f.type === "button" ? "flex-shrink-0" : "flex-1"}
405
+ style={
406
+ fAdmin?.width ? { width: fAdmin.width, flex: "none" } : {}
407
+ }
408
+ >
409
+ {renderField(f, parentData, onParentChange)}
84
410
  </div>
85
- </div>
86
- ))}
411
+ );
412
+ })}
87
413
  </div>
88
414
  );
89
415
  }
90
416
 
91
417
  switch (field.type) {
92
- case "text":
93
- case "email":
418
+ case "tabs": {
419
+ const fieldTabs = (field as any).tabs;
420
+ const currentTab = fieldTabs[activeTab] || fieldTabs[0];
421
+
94
422
  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>
423
+ <div
424
+ key={field.name || `tabs-${Math.random()}`}
425
+ className="space-y-8"
426
+ >
427
+ <div className="flex items-center gap-1 border-b border-[var(--kyro-border)] mb-6">
428
+ {fieldTabs.map((tab: any, index: number) => (
429
+ <button
430
+ key={index}
431
+ type="button"
432
+ className={`px-6 py-3 text-sm font-bold transition-all border-b-2 -mb-[1px] ${
433
+ activeTab === index
434
+ ? "border-[var(--kyro-text-primary)] text-[var(--kyro-text-primary)]"
435
+ : "border-transparent text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
436
+ }`}
437
+ onClick={() => setActiveTab(index)}
438
+ >
439
+ {tab.label}
440
+ </button>
441
+ ))}
442
+ </div>
443
+ <div className="space-y-6">
444
+ {currentTab?.fields.map((f: Field) =>
445
+ renderField(f, parentData, onParentChange),
100
446
  )}
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>
447
+ </div>
448
+
449
+ {currentTab?.label === "SEO" && (
450
+ <div className="mt-12 pt-8 border-t border-[var(--kyro-border)]">
451
+ <h4 className="text-xs font-bold text-[var(--kyro-text-secondary)] uppercase tracking-[0.2em] mb-6 opacity-50">
452
+ Live Google Preview
453
+ </h4>
454
+ <SeoPreview
455
+ title={formData.metaTitle || formData.title || "Untitled"}
456
+ description={
457
+ formData.metaDescription || "Please enter a description..."
458
+ }
459
+ slug={formData.slug || "your-slug"}
460
+ />
461
+ </div>
120
462
  )}
121
- {error && <p className="kyro-form-error">{error}</p>}
122
463
  </div>
123
464
  );
465
+ }
466
+
467
+ case "text":
468
+ case "email":
469
+ const textValue = currentData[field.name!];
470
+ const isKeyHidden = String(textValue).startsWith("••");
124
471
 
125
- case "password":
126
472
  return (
127
473
  <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>
474
+ <label className="kyro-form-label flex items-center justify-between">
475
+ <div className="flex items-center gap-2">
476
+ {field.label || field.name}
477
+ {field.required && (
478
+ <span className="kyro-form-label-required">*</span>
479
+ )}
480
+ </div>
481
+ {(field.admin?.autoGenerate || field.admin?.readOnly) && (
482
+ <button
483
+ type="button"
484
+ onClick={async (e) => {
485
+ e.preventDefault();
486
+ e.stopPropagation();
487
+
488
+ if (field.admin?.autoGenerate === "key") {
489
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
490
+ let suffix = "";
491
+ for (let i = 0; i < 32; i++) {
492
+ suffix +=
493
+ chars[Math.floor(Math.random() * chars.length)];
494
+ }
495
+ onFieldChange(`kyro_${suffix}`);
496
+ } else if (
497
+ field.admin?.readOnly &&
498
+ textValue &&
499
+ !isKeyHidden
500
+ ) {
501
+ await navigator.clipboard.writeText(String(textValue));
502
+ // Store the actual key in a temp var and show hidden
503
+ const actualKey = textValue;
504
+ onFieldChange(actualKey + "__COPIED__");
505
+ setTimeout(
506
+ () => onFieldChange("••••••••••••••••••••••••••••••"),
507
+ 100,
508
+ );
509
+ }
510
+ }}
511
+ className="p-1.5 rounded-lg text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
512
+ title={
513
+ field.admin?.autoGenerate === "key"
514
+ ? "Generate new key"
515
+ : "Copy to clipboard"
516
+ }
517
+ >
518
+ {field.admin?.autoGenerate === "key" ? (
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 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" />
528
+ </svg>
529
+ ) : (
530
+ <svg
531
+ width="14"
532
+ height="14"
533
+ viewBox="0 0 24 24"
534
+ fill="none"
535
+ stroke="currentColor"
536
+ strokeWidth="2"
537
+ >
538
+ <rect x="8" y="8" width="12" height="12" rx="2" />
539
+ <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
540
+ </svg>
541
+ )}
542
+ </button>
132
543
  )}
133
544
  </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
- />
545
+ {field.name === "slug" ? (
546
+ <div className="flex items-center gap-2">
547
+ <div className="relative flex-1">
548
+ <input
549
+ type="text"
550
+ className={`kyro-form-input pr-24 ${isSlugLocked ? "opacity-70 bg-[var(--kyro-bg-secondary)]" : ""}`}
551
+ value={value || ""}
552
+ onChange={(e) => onFieldChange(e.target.value)}
553
+ disabled={isSlugLocked || disabled}
554
+ />
555
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
556
+ {!isSlugLocked && (
557
+ <button
558
+ type="button"
559
+ onClick={() =>
560
+ onFieldChange(slugifyText(formData.title || ""))
561
+ }
562
+ className="p-1 text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-primary)]"
563
+ >
564
+ <svg
565
+ width="12"
566
+ height="12"
567
+ viewBox="0 0 24 24"
568
+ fill="none"
569
+ stroke="currentColor"
570
+ strokeWidth="2.5"
571
+ >
572
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
573
+ <path d="M21 3v5h-5" />
574
+ </svg>
575
+ </button>
576
+ )}
577
+ <button
578
+ type="button"
579
+ onClick={() => setIsSlugLocked(!isSlugLocked)}
580
+ className={`p-1.5 rounded ${isSlugLocked ? "text-[var(--kyro-primary)]" : "text-[var(--kyro-text-secondary)]"}`}
581
+ >
582
+ {isSlugLocked ? (
583
+ <svg
584
+ width="12"
585
+ height="12"
586
+ viewBox="0 0 24 24"
587
+ fill="none"
588
+ stroke="currentColor"
589
+ strokeWidth="2.5"
590
+ >
591
+ <rect
592
+ x="3"
593
+ y="11"
594
+ width="18"
595
+ height="11"
596
+ rx="2"
597
+ ry="2"
598
+ />
599
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
600
+ </svg>
601
+ ) : (
602
+ <svg
603
+ width="12"
604
+ height="12"
605
+ viewBox="0 0 24 24"
606
+ fill="none"
607
+ stroke="currentColor"
608
+ strokeWidth="2.5"
609
+ >
610
+ <rect
611
+ x="3"
612
+ y="11"
613
+ width="18"
614
+ height="11"
615
+ rx="2"
616
+ ry="2"
617
+ />
618
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
619
+ </svg>
620
+ )}
621
+ </button>
622
+ </div>
623
+ </div>
624
+ </div>
625
+ ) : (
626
+ <input
627
+ type={(field as any).variant === "url" ? "url" : "text"}
628
+ className="kyro-form-input"
629
+ value={value || ""}
630
+ onChange={(e) => onFieldChange(e.target.value)}
631
+ disabled={disabled}
632
+ />
633
+ )}
634
+ {field.name?.toLowerCase().includes("metatitle") && (
635
+ <div className="flex items-center justify-between mt-1 text-[10px] font-bold uppercase tracking-wider">
636
+ <span
637
+ className={
638
+ (value?.length || 0) > 60
639
+ ? "text-red-500"
640
+ : (value?.length || 0) >= 40
641
+ ? "text-green-500"
642
+ : "text-amber-600"
643
+ }
644
+ >
645
+ {value?.length || 0} / 60 —{" "}
646
+ {(value?.length || 0) > 60
647
+ ? "Too Long"
648
+ : (value?.length || 0) >= 40
649
+ ? "Ideal"
650
+ : "Short"}
651
+ </span>
652
+ </div>
653
+ )}
142
654
  {error && <p className="kyro-form-error">{error}</p>}
143
655
  </div>
144
656
  );
@@ -146,104 +658,182 @@ export function AutoForm({
146
658
  case "textarea":
147
659
  return (
148
660
  <div key={field.name} className="kyro-form-field">
149
- <label className="kyro-form-label">
661
+ <label className="kyro-form-label flex items-center justify-between">
150
662
  {field.label || field.name}
151
- {field.required && (
152
- <span className="kyro-form-label-required">*</span>
663
+ {field.admin?.autoGenerate && (
664
+ <button
665
+ type="button"
666
+ onClick={() =>
667
+ onFieldChange(
668
+ stripHtml(
669
+ formData[field.admin!.autoGenerate!] || "",
670
+ ).slice(0, 160),
671
+ )
672
+ }
673
+ className="p-1 text-[var(--kyro-text-secondary)]"
674
+ >
675
+ <svg
676
+ width="14"
677
+ height="14"
678
+ viewBox="0 0 24 24"
679
+ fill="none"
680
+ stroke="currentColor"
681
+ strokeWidth="2"
682
+ >
683
+ <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" />
684
+ </svg>
685
+ </button>
153
686
  )}
154
687
  </label>
155
688
  <textarea
156
- className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
689
+ className="kyro-form-input kyro-form-textarea"
157
690
  value={value || ""}
158
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
691
+ onChange={(e) => onFieldChange(e.target.value)}
159
692
  disabled={disabled}
160
- rows={(field as any).rows || 4}
161
- placeholder={`Enter ${field.label || field.name}`}
693
+ rows={4}
162
694
  />
163
- {field.admin?.description && !error && (
164
- <p className="kyro-form-help">{field.admin.description}</p>
695
+ {field.name?.toLowerCase().includes("metadescription") && (
696
+ <div className="mt-1 text-[10px] font-bold uppercase tracking-wider">
697
+ <span
698
+ className={
699
+ (value?.length || 0) > 160
700
+ ? "text-red-500"
701
+ : (value?.length || 0) >= 120
702
+ ? "text-green-500"
703
+ : "text-amber-600"
704
+ }
705
+ >
706
+ {value?.length || 0} / 160 —{" "}
707
+ {(value?.length || 0) > 160
708
+ ? "Too Long"
709
+ : (value?.length || 0) >= 120
710
+ ? "Ideal"
711
+ : "Short"}
712
+ </span>
713
+ </div>
165
714
  )}
166
- {error && <p className="kyro-form-error">{error}</p>}
167
715
  </div>
168
716
  );
169
717
 
170
- case "number":
718
+ case "richtext":
719
+ // TEMP: replaced with textarea to isolate hydration crash
171
720
  return (
172
721
  <div key={field.name} className="kyro-form-field">
173
722
  <label className="kyro-form-label">
174
723
  {field.label || field.name}
175
- {field.required && (
176
- <span className="kyro-form-label-required">*</span>
177
- )}
178
724
  </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)
725
+ <textarea
726
+ className="kyro-form-input kyro-form-textarea"
727
+ rows={10}
728
+ value={
729
+ typeof value === "string"
730
+ ? value
731
+ : value
732
+ ? JSON.stringify(value, null, 2)
733
+ : ""
185
734
  }
735
+ onChange={(e) => onFieldChange(e.target.value)}
186
736
  disabled={disabled}
187
- placeholder="0"
188
- min={(field as any).min}
189
- max={(field as any).max}
190
- step={(field as any).step || "any"}
737
+ placeholder="Enter content..."
191
738
  />
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
739
  </div>
197
740
  );
198
741
 
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">
742
+ case "group":
743
+ if ("fields" in field) {
744
+ const groupData = value || {};
745
+ return (
746
+ <div key={field.name} className="kyro-form-group">
747
+ <h3 className="kyro-form-group-title">
212
748
  {field.label || field.name}
213
- </span>
214
- </label>
215
- {field.admin?.description && (
216
- <p className="kyro-form-help">{field.admin.description}</p>
217
- )}
218
- </div>
219
- );
749
+ </h3>
750
+ <div className="kyro-form-group-fields">
751
+ {(field as any).fields.map((f: Field) =>
752
+ renderField(f, groupData, onFieldChange),
753
+ )}
754
+ </div>
755
+ </div>
756
+ );
757
+ }
758
+ return null;
220
759
 
221
- case "date":
760
+ case "array":
761
+ if ("fields" in field) {
762
+ const items = Array.isArray(value) ? value : [];
763
+ return (
764
+ <div key={field.name} className="kyro-form-field">
765
+ <label className="kyro-form-label">
766
+ {field.label || field.name}
767
+ </label>
768
+ <div className="kyro-form-array">
769
+ {items.map((item: any, index: number) => (
770
+ <div key={index} className="kyro-form-array-item">
771
+ <div className="flex justify-between mb-2">
772
+ <span className="text-xs font-bold opacity-50">
773
+ Item {index + 1}
774
+ </span>
775
+ <button
776
+ type="button"
777
+ className="text-red-500"
778
+ onClick={() =>
779
+ onFieldChange(items.filter((_, i) => i !== index))
780
+ }
781
+ >
782
+ Remove
783
+ </button>
784
+ </div>
785
+ {(field as any).fields.map((f: Field) =>
786
+ renderField(f, item, (newItem) => {
787
+ const newItems = [...items];
788
+ newItems[index] = newItem;
789
+ onFieldChange(newItems);
790
+ }),
791
+ )}
792
+ </div>
793
+ ))}
794
+ <button
795
+ type="button"
796
+ className="kyro-btn kyro-btn-secondary kyro-btn-sm"
797
+ onClick={() => onFieldChange([...items, {}])}
798
+ >
799
+ Add Item
800
+ </button>
801
+ </div>
802
+ </div>
803
+ );
804
+ }
805
+ return null;
806
+
807
+ case "blocks":
222
808
  return (
223
809
  <div key={field.name} className="kyro-form-field">
224
810
  <label className="kyro-form-label">
225
811
  {field.label || field.name}
226
- {field.required && (
227
- <span className="kyro-form-label-required">*</span>
228
- )}
229
812
  </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
- )
813
+ <textarea
814
+ className="kyro-form-input kyro-form-textarea"
815
+ rows={10}
816
+ value={
817
+ typeof value === "string"
818
+ ? value
819
+ : value
820
+ ? JSON.stringify(value, null, 2)
821
+ : ""
239
822
  }
823
+ onChange={(e) => {
824
+ try {
825
+ onFieldChange(JSON.parse(e.target.value));
826
+ } catch {
827
+ onFieldChange(e.target.value);
828
+ }
829
+ }}
240
830
  disabled={disabled}
831
+ placeholder="Blocks data (JSON)..."
241
832
  />
242
- {error && <p className="kyro-form-error">{error}</p>}
243
833
  </div>
244
834
  );
245
835
 
246
- case "select":
836
+ case "number":
247
837
  return (
248
838
  <div key={field.name} className="kyro-form-field">
249
839
  <label className="kyro-form-label">
@@ -252,57 +842,31 @@ export function AutoForm({
252
842
  <span className="kyro-form-label-required">*</span>
253
843
  )}
254
844
  </label>
255
- <select
256
- className={`kyro-form-input kyro-form-select ${error ? "kyro-form-input-error" : ""}`}
257
- value={value || ""}
258
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
845
+ <NumberField
846
+ field={field as any}
847
+ value={value}
848
+ onChange={(newValue) => onFieldChange(newValue)}
259
849
  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
- )}
271
- {error && <p className="kyro-form-error">{error}</p>}
850
+ error={error}
851
+ />
272
852
  </div>
273
853
  );
274
854
 
275
- case "radio":
855
+ case "checkbox":
276
856
  return (
277
857
  <div key={field.name} className="kyro-form-field">
278
- <label className="kyro-form-label">
279
- {field.label || field.name}
280
- {field.required && (
281
- <span className="kyro-form-label-required">*</span>
282
- )}
283
- </label>
284
- <div className="kyro-form-radio-group">
285
- {(field as any).options?.map((opt: any) => (
286
- <label key={opt.value || opt} className="kyro-form-radio">
287
- <input
288
- type="radio"
289
- 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
- }
295
- disabled={disabled}
296
- />
297
- <span>{opt.label || opt}</span>
298
- </label>
299
- ))}
300
- </div>
858
+ <CheckboxField
859
+ field={field as any}
860
+ value={value}
861
+ onChange={(newValue) => onFieldChange(newValue)}
862
+ disabled={disabled}
863
+ error={error}
864
+ />
301
865
  {error && <p className="kyro-form-error">{error}</p>}
302
866
  </div>
303
867
  );
304
868
 
305
- case "color":
869
+ case "select":
306
870
  return (
307
871
  <div key={field.name} className="kyro-form-field">
308
872
  <label className="kyro-form-label">
@@ -311,23 +875,18 @@ export function AutoForm({
311
875
  <span className="kyro-form-label-required">*</span>
312
876
  )}
313
877
  </label>
314
- <div className="kyro-form-color-wrapper">
315
- <input
316
- type="color"
317
- className={`kyro-form-color ${error ? "kyro-form-input-error" : ""}`}
318
- value={value || "#000000"}
319
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
320
- disabled={disabled}
321
- />
322
- <span className="kyro-form-color-value">
323
- {value || "#000000"}
324
- </span>
325
- </div>
878
+ <SelectField
879
+ field={field as any}
880
+ value={value}
881
+ onChange={(newValue) => onFieldChange(newValue)}
882
+ disabled={disabled}
883
+ error={error}
884
+ />
326
885
  {error && <p className="kyro-form-error">{error}</p>}
327
886
  </div>
328
887
  );
329
888
 
330
- case "json":
889
+ case "date":
331
890
  return (
332
891
  <div key={field.name} className="kyro-form-field">
333
892
  <label className="kyro-form-label">
@@ -336,56 +895,43 @@ export function AutoForm({
336
895
  <span className="kyro-form-label-required">*</span>
337
896
  )}
338
897
  </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);
351
- }
352
- }}
898
+ <DateField
899
+ field={field as any}
900
+ value={value}
901
+ onChange={(newValue) => onFieldChange(newValue)}
353
902
  disabled={disabled}
354
- rows={6}
355
- placeholder='{"key": "value"}'
903
+ error={error}
356
904
  />
357
- {field.admin?.description && !error && (
358
- <p className="kyro-form-help">{field.admin.description}</p>
359
- )}
360
905
  {error && <p className="kyro-form-error">{error}</p>}
361
906
  </div>
362
907
  );
363
908
 
364
- case "markdown":
909
+ case "password":
365
910
  return (
366
911
  <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
- )}
912
+ <label className="kyro-form-label flex items-center justify-between">
913
+ <div className="flex items-center gap-2">
914
+ {field.label || field.name}
915
+ {field.required && (
916
+ <span className="kyro-form-label-required">*</span>
917
+ )}
918
+ </div>
372
919
  </label>
373
- <textarea
374
- className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
920
+ <input
921
+ type="password"
922
+ className="kyro-form-input"
375
923
  value={value || ""}
376
- onChange={(e) => handleFieldChange(field.name!, e.target.value)}
924
+ onChange={(e) => onFieldChange(e.target.value)}
377
925
  disabled={disabled}
378
- rows={8}
379
- placeholder="Enter markdown content..."
926
+ placeholder={
927
+ field.admin?.placeholder || `Enter ${field.label || field.name}`
928
+ }
380
929
  />
381
- {field.admin?.description && !error && (
382
- <p className="kyro-form-help">{field.admin.description}</p>
383
- )}
384
930
  {error && <p className="kyro-form-error">{error}</p>}
385
931
  </div>
386
932
  );
387
933
 
388
- case "code":
934
+ case "radio":
389
935
  return (
390
936
  <div key={field.name} className="kyro-form-field">
391
937
  <label className="kyro-form-label">
@@ -394,281 +940,140 @@ export function AutoForm({
394
940
  <span className="kyro-form-label-required">*</span>
395
941
  )}
396
942
  </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
- )}
943
+ <div className="kyro-form-radio-group">
944
+ {((field as any).options || []).map((opt: any) => (
945
+ <label key={opt.value} className="kyro-form-radio-label">
946
+ <input
947
+ type="radio"
948
+ name={field.name}
949
+ value={opt.value}
950
+ checked={value === opt.value}
951
+ onChange={() => onFieldChange(opt.value)}
952
+ disabled={disabled}
953
+ className="kyro-form-radio"
954
+ />
955
+ <span>{opt.label || opt.value}</span>
956
+ </label>
957
+ ))}
958
+ </div>
408
959
  {error && <p className="kyro-form-error">{error}</p>}
409
960
  </div>
410
961
  );
411
962
 
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
- }
428
- return null;
429
-
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
- )}
505
- <button
506
- 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
- }
963
+ case "color":
964
+ return (
965
+ <div key={field.name} className="kyro-form-field">
966
+ <label className="kyro-form-label flex items-center gap-2">
967
+ {field.label || field.name}
968
+ {field.required && (
969
+ <span className="kyro-form-label-required">*</span>
970
+ )}
971
+ {value && (
972
+ <span
973
+ className="w-5 h-5 rounded border border-[var(--kyro-border)] shrink-0"
974
+ style={{ backgroundColor: value }}
975
+ />
976
+ )}
977
+ </label>
978
+ <div className="flex items-center gap-3">
979
+ <input
980
+ type="color"
981
+ value={value || "#000000"}
982
+ onChange={(e) => onFieldChange(e.target.value)}
983
+ disabled={disabled}
984
+ className="kyro-form-input h-10 w-14 p-1 cursor-pointer"
985
+ />
986
+ <input
987
+ type="text"
988
+ className="kyro-form-input font-mono uppercase"
989
+ value={value || ""}
990
+ onChange={(e) => onFieldChange(e.target.value)}
991
+ disabled={disabled}
992
+ placeholder="#000000"
993
+ />
994
+ </div>
995
+ {error && <p className="kyro-form-error">{error}</p>}
996
+ </div>
997
+ );
998
+
999
+ case "markdown":
1000
+ return (
1001
+ <MarkdownField
1002
+ key={field.name}
1003
+ field={field as any}
1004
+ value={value || ""}
1005
+ onChange={(val) => onFieldChange(val)}
1006
+ disabled={disabled}
1007
+ />
1008
+ );
1009
+
1010
+ case "button": {
1011
+ const isLoading = loadingFields[field.name!];
1012
+ return (
1013
+ <div key={field.name} className="kyro-form-field">
1014
+ <button
1015
+ type="button"
1016
+ disabled={isLoading || disabled}
1017
+ onClick={async () => {
1018
+ const action = field.admin?.action || (field as any).action;
1019
+ const method =
1020
+ field.admin?.method || (field as any).method || "POST";
1021
+ if (action) {
1022
+ setLoadingFields((prev) => ({
1023
+ ...prev,
1024
+ [field.name!]: true,
1025
+ }));
1026
+ try {
1027
+ const response = await fetch(action, {
1028
+ method,
1029
+ headers: { "Content-Type": "application/json" },
1030
+ body: JSON.stringify(formData),
514
1031
  });
515
- handleFieldChange(field.name!, [...items, newItem]);
516
- }}
517
- disabled={disabled}
1032
+ const result = await response.json();
1033
+ if (response.ok) {
1034
+ // handle result
1035
+ } else {
1036
+ // handle error
1037
+ }
1038
+ } catch (err) {
1039
+ console.error("Error executing action:", err);
1040
+ } finally {
1041
+ setLoadingFields((prev) => ({
1042
+ ...prev,
1043
+ [field.name!]: false,
1044
+ }));
1045
+ }
1046
+ }
1047
+ }}
1048
+ 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" : ""}`}
1049
+ >
1050
+ {isLoading && (
1051
+ <svg
1052
+ className="animate-spin h-3 w-3 text-white"
1053
+ xmlns="http://www.w3.org/2000/svg"
1054
+ fill="none"
1055
+ viewBox="0 0 24 24"
518
1056
  >
519
- <svg
520
- width="14"
521
- height="14"
522
- viewBox="0 0 24 24"
523
- fill="none"
1057
+ <circle
1058
+ className="opacity-25"
1059
+ cx="12"
1060
+ cy="12"
1061
+ r="10"
524
1062
  stroke="currentColor"
525
- strokeWidth="2"
526
- >
527
- <path d="M12 5v14M5 12h14" />
528
- </svg>
529
- Add {labels.singular}
530
- </button>
531
- </div>
532
- </div>
533
- );
534
- }
535
- return null;
536
-
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}
600
- >
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) => (
652
- <button
653
- key={block.slug}
654
- type="button"
655
- className="kyro-btn kyro-btn-secondary kyro-btn-sm"
656
- onClick={() => {
657
- const newBlock = { blockType: block.slug };
658
- handleFieldChange(field.name!, [...blocks, newBlock]);
659
- }}
660
- disabled={disabled}
661
- >
662
- {block.label}
663
- </button>
664
- ))}
665
- </div>
666
- </div>
667
- </div>
668
- </div>
669
- );
670
- }
671
- return null;
1063
+ strokeWidth="4"
1064
+ ></circle>
1065
+ <path
1066
+ className="opacity-75"
1067
+ fill="currentColor"
1068
+ 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"
1069
+ ></path>
1070
+ </svg>
1071
+ )}
1072
+ {isLoading ? "Processing..." : field.label || "Click"}
1073
+ </button>
1074
+ </div>
1075
+ );
1076
+ }
672
1077
 
673
1078
  case "relationship":
674
1079
  return (
@@ -676,143 +1081,435 @@ export function AutoForm({
676
1081
  key={field.name}
677
1082
  field={field as any}
678
1083
  value={value}
679
- onChange={(newValue) => handleFieldChange(field.name!, newValue)}
1084
+ onChange={(newValue) => onFieldChange(newValue)}
680
1085
  disabled={disabled}
681
1086
  error={error}
682
1087
  />
683
1088
  );
684
1089
 
685
1090
  case "upload":
1091
+ case "image":
686
1092
  return (
687
- <UploadField
1093
+ <ImageField
688
1094
  key={field.name}
689
1095
  field={field as any}
690
1096
  value={value}
691
- onChange={(newValue) => handleFieldChange(field.name!, newValue)}
1097
+ onChange={(newValue) => onFieldChange(newValue)}
692
1098
  disabled={disabled}
693
- error={error}
694
1099
  />
695
1100
  );
696
1101
 
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
- )}
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>
1102
+ default:
1103
+ return null;
1104
+ }
1105
+ };
1106
+
1107
+ const renderHeader = () => {
1108
+ const docTitle = formData.title || formData.name || "Untitled";
1109
+ const status = formData.status || "draft";
1110
+ const lastModified = formData.updatedAt
1111
+ ? new Date(formData.updatedAt).toLocaleString()
1112
+ : "Just now";
1113
+ const createdAt = formData.createdAt
1114
+ ? new Date(formData.createdAt).toLocaleString()
1115
+ : "Just now";
1116
+
1117
+ return (
1118
+ <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">
1119
+ <div className="flex flex-col gap-1">
1120
+ <div className="flex items-center gap-4">
1121
+ <a
1122
+ href={`/${collectionSlug}`}
1123
+ className="p-2 border border-[var(--kyro-border)] rounded-xl hover:bg-[var(--kyro-bg-secondary)] transition-colors"
1124
+ >
1125
+ <svg
1126
+ className="w-4 h-4"
1127
+ fill="none"
1128
+ stroke="currentColor"
1129
+ viewBox="0 0 24 24"
1130
+ >
1131
+ <path
1132
+ strokeLinecap="round"
1133
+ strokeLinejoin="round"
1134
+ strokeWidth="2.5"
1135
+ d="M15 19l-7-7 7-7"
1136
+ />
1137
+ </svg>
1138
+ </a>
1139
+ <h1 className="text-xl font-bold tracking-tighter">{docTitle}</h1>
1140
+ </div>
1141
+ <div className="flex items-center gap-4 text-[11px] font-medium tracking-wide opacity-60 ml-12">
1142
+ <span className="flex items-center gap-1.5 capitalize">
1143
+ <span
1144
+ className={`h-1.5 w-1.5 rounded-full ${status === "published" ? "bg-green-500" : "bg-amber-500"}`}
1145
+ />
1146
+ {status}
1147
+ </span>
1148
+ {hasUnsavedChanges && (
1149
+ <>
1150
+ <span className="opacity-30">—</span>
1151
+ <button
1152
+ type="button"
1153
+ onClick={() => setFormData(initialData)}
1154
+ className="text-[var(--kyro-primary)] hover:underline"
1155
+ >
1156
+ Revert to published
1157
+ </button>
1158
+ </>
716
1159
  )}
717
- {error && <p className="kyro-form-error">{error}</p>}
1160
+ <span className="border-l border-[var(--kyro-border)] pl-4">
1161
+ Modified {lastModified}
1162
+ </span>
1163
+ <span className="border-l border-[var(--kyro-border)] pl-4">
1164
+ Created {createdAt}
1165
+ </span>
718
1166
  </div>
719
- );
1167
+ </div>
720
1168
 
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>
1169
+ <div className="flex items-center gap-6">
1170
+ <div className="flex items-center gap-1 bg-[var(--kyro-bg-secondary)] p-1 rounded-xl border border-[var(--kyro-border)]">
1171
+ {["edit", "version", "api"].map((v) => (
1172
+ <button
1173
+ key={v}
1174
+ type="button"
1175
+ onClick={() => setView(v as any)}
1176
+ 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"}`}
1177
+ >
1178
+ {v.toUpperCase()}
1179
+ </button>
1180
+ ))}
728
1181
  </div>
729
- );
730
- }
1182
+
1183
+ <div className="h-8 w-px bg-[var(--kyro-border)] mx-2" />
1184
+
1185
+ <div className="flex items-center gap-3">
1186
+ <button
1187
+ type="button"
1188
+ onClick={() => setShowPreview(!showPreview)}
1189
+ 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)]"}`}
1190
+ title="Live Preview"
1191
+ >
1192
+ <svg
1193
+ width="20"
1194
+ height="20"
1195
+ viewBox="0 0 24 24"
1196
+ fill="none"
1197
+ stroke="currentColor"
1198
+ strokeWidth="2"
1199
+ >
1200
+ <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" />
1201
+ </svg>
1202
+ {showPreview && (
1203
+ <span className="text-[10px] font-black uppercase tracking-widest pr-1">
1204
+ Active
1205
+ </span>
1206
+ )}
1207
+ </button>
1208
+ <button
1209
+ type="button"
1210
+ className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
1211
+ title="Desktop View"
1212
+ >
1213
+ <svg
1214
+ width="20"
1215
+ height="20"
1216
+ viewBox="0 0 24 24"
1217
+ fill="none"
1218
+ stroke="currentColor"
1219
+ strokeWidth="2"
1220
+ >
1221
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
1222
+ <line x1="15" y1="3" x2="15" y2="21" />
1223
+ </svg>
1224
+ </button>
1225
+
1226
+ <button
1227
+ id="btn-save"
1228
+ type="button"
1229
+ onClick={() =>
1230
+ (document.getElementById("btn-save") as any)?.click()
1231
+ }
1232
+ className="kyro-btn kyro-btn-primary px-6 py-2.5 text-xs rounded-xl shadow-lg transition-all"
1233
+ >
1234
+ Publish Changes
1235
+ </button>
1236
+
1237
+ <button
1238
+ type="button"
1239
+ className="p-2.5 text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-bg-secondary)] rounded-xl transition-all"
1240
+ >
1241
+ <svg
1242
+ width="20"
1243
+ height="20"
1244
+ viewBox="0 0 24 24"
1245
+ fill="none"
1246
+ stroke="currentColor"
1247
+ strokeWidth="3"
1248
+ >
1249
+ <circle cx="12" cy="12" r="1.5" fill="currentColor" />
1250
+ <circle cx="12" cy="5" r="1.5" fill="currentColor" />
1251
+ <circle cx="12" cy="19" r="1.5" fill="currentColor" />
1252
+ </svg>
1253
+ </button>
1254
+ </div>
1255
+ </div>
1256
+ </header>
1257
+ );
1258
+ };
1259
+
1260
+ const renderEditView = () => {
1261
+ // Single layout: no split grid, no sidebar column — just a clean field list
1262
+ if (layout === "single") {
1263
+ return (
1264
+ <div className="w-full space-y-8">
1265
+ <div className="surface-tile p-8 space-y-8">
1266
+ {config.fields.map((f) => renderField(f))}
1267
+ </div>
1268
+ </div>
1269
+ );
731
1270
  }
1271
+
1272
+ // Default split layout
1273
+ return (
1274
+ <div
1275
+ className={`w-full mx-auto grid gap-8 pb-32 transition-all duration-700 ${showPreview ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1 lg:grid-cols-[1fr_380px]"}`}
1276
+ >
1277
+ <div className="space-y-8 animate-in fade-in slide-in-from-left-4 duration-500">
1278
+ {config.tabs ? (
1279
+ renderField({ type: "tabs", tabs: config.tabs } as any)
1280
+ ) : (
1281
+ <div className="surface-tile p-8 space-y-8">
1282
+ {config.fields
1283
+ .filter(
1284
+ (f) => !f.admin?.position || f.admin.position === "main",
1285
+ )
1286
+ .map((f) => renderField(f))}
1287
+ </div>
1288
+ )}
1289
+ </div>
1290
+
1291
+ {showPreview ? (
1292
+ <div className="sticky top-36 h-[calc(100vh-280px)] animate-in fade-in slide-in-from-right-10 duration-700">
1293
+ <div className="w-full h-full rounded-3xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] shadow-2xl overflow-hidden relative group">
1294
+ <div className="absolute top-4 left-4 z-10 flex items-center gap-2">
1295
+ <div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
1296
+ <span className="text-[10px] font-black uppercase tracking-widest text-white/60">
1297
+ Live Preview Mode
1298
+ </span>
1299
+ </div>
1300
+ <iframe
1301
+ src={`/${collectionSlug}/${formData.slug || formData.id}?preview=true`}
1302
+ className="w-full h-full border-none"
1303
+ title="Live Preview"
1304
+ />
1305
+ <div className="absolute inset-0 bg-transparent pointer-events-none border-[12px] border-[var(--kyro-surface)] rounded-3xl" />
1306
+ </div>
1307
+ </div>
1308
+ ) : (
1309
+ <div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
1310
+ {config.fields.some((f) => f.admin?.position === "sidebar") && (
1311
+ <div className="surface-tile p-6 space-y-6">
1312
+ <h3 className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
1313
+ Sidebar Settings
1314
+ </h3>
1315
+ {config.fields
1316
+ .filter((f) => f.admin?.position === "sidebar")
1317
+ .map((f) => renderField(f))}
1318
+ </div>
1319
+ )}
1320
+ </div>
1321
+ )}
1322
+ </div>
1323
+ );
732
1324
  };
733
1325
 
734
- return (
735
- <div className="kyro-form">
736
- {config.fields.map((field) => renderField(field))}
1326
+ const renderVersionView = () => (
1327
+ <div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 px-8 pb-12">
1328
+ <div className="surface-tile p-8">
1329
+ <h2 className="text-2xl font-black mb-2">Version History</h2>
1330
+ <p className="text-[11px] text-[var(--kyro-text-secondary)] opacity-50 mb-8 uppercase tracking-widest font-bold">
1331
+ Snapshots are created automatically every time you save changes.
1332
+ </p>
1333
+
1334
+ {loadingVersions ? (
1335
+ <div className="flex justify-center py-20">
1336
+ <span className="animate-spin text-[var(--kyro-primary)]">⌛</span>
1337
+ </div>
1338
+ ) : versions.length === 0 ? (
1339
+ <p className="opacity-50 font-medium py-12 text-center italic">
1340
+ No previous versions found for this document.
1341
+ </p>
1342
+ ) : (
1343
+ <div className="space-y-4">
1344
+ {versions.map((v, i) => (
1345
+ <div
1346
+ key={v.id}
1347
+ className="p-6 rounded-2xl border border-[var(--kyro-border)] bg-[var(--kyro-bg-secondary)] flex items-center justify-between group hover:border-[var(--kyro-primary)] transition-all duration-300"
1348
+ >
1349
+ <div className="flex items-center gap-6">
1350
+ <div className="w-12 h-12 rounded-2xl bg-[var(--kyro-bg-secondary)] flex items-center justify-center border border-[var(--kyro-border)]">
1351
+ <span className="text-[10px] font-black opacity-40">
1352
+ v{versions.length - i}
1353
+ </span>
1354
+ </div>
1355
+ <div>
1356
+ <div className="flex items-center gap-3 mb-1">
1357
+ <span className="text-sm font-black text-[var(--kyro-text-primary)]">
1358
+ {new Date(v.createdAt).toLocaleString("en-US", {
1359
+ month: "short",
1360
+ day: "numeric",
1361
+ year: "numeric",
1362
+ hour: "2-digit",
1363
+ minute: "2-digit",
1364
+ })}
1365
+ </span>
1366
+ </div>
1367
+ <p className="text-[11px] text-[var(--kyro-text-secondary)] font-medium italic opacity-60">
1368
+ System captured snapshot
1369
+ </p>
1370
+ </div>
1371
+ </div>
1372
+ <div className="flex items-center gap-3">
1373
+ <button
1374
+ type="button"
1375
+ onClick={() => handleRestoreVersion(v.id)}
1376
+ className="px-5 py-2.5 rounded-xl border border-[var(--kyro-border)] text-[var(--kyro-text-primary)] text-xs font-black uppercase tracking-widest hover:bg-[var(--kyro-primary)] hover:text-white hover:border-[var(--kyro-primary)] transition-all active:scale-95 shadow-lg group-hover:shadow-[var(--kyro-primary)]"
1377
+ >
1378
+ Restore
1379
+ </button>
1380
+ </div>
1381
+ </div>
1382
+ ))}
1383
+ </div>
1384
+ )}
1385
+ </div>
737
1386
  </div>
738
1387
  );
739
- }
740
1388
 
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":
751
- 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
- />
814
- );
815
- }
1389
+ const renderApiView = () => (
1390
+ <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4">
1391
+ <div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
1392
+ <div className="surface-tile p-8 min-w-0">
1393
+ <h2 className="text-xl font-black mb-6">Response Payload</h2>
1394
+ <div className="bg-[#0f172a] p-6 rounded-2xl border border-white/5 overflow-x-auto max-h-[800px]">
1395
+ <pre className="text-blue-300 text-xs font-mono whitespace-pre-wrap break-all">
1396
+ {JSON.stringify(formData, null, 2)}
1397
+ </pre>
1398
+ </div>
1399
+ </div>
1400
+
1401
+ <div className="space-y-6">
1402
+ <div className="surface-tile p-8 space-y-6">
1403
+ <h2 className="text-xl font-black mb-6">API Info</h2>
1404
+
1405
+ <div className="space-y-6">
1406
+ <div>
1407
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
1408
+ Reference Path
1409
+ </label>
1410
+ <div className="relative group">
1411
+ <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">
1412
+ {`/api/${collectionSlug}/${formData.id || ""}`}
1413
+ </code>
1414
+ </div>
1415
+ </div>
1416
+
1417
+ <div>
1418
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-3">
1419
+ Methods Allowed
1420
+ </label>
1421
+ <div className="flex gap-2">
1422
+ <span className="px-3 py-1.5 bg-green-500/10 text-green-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
1423
+ GET
1424
+ </span>
1425
+ <span className="px-3 py-1.5 bg-amber-500/10 text-amber-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
1426
+ PATCH
1427
+ </span>
1428
+ <span className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-lg font-black text-[9px] uppercase tracking-wider">
1429
+ DELETE
1430
+ </span>
1431
+ </div>
1432
+ </div>
1433
+
1434
+ <div className="pt-6 border-t border-[var(--kyro-border)]">
1435
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-4">
1436
+ Security Policy
1437
+ </label>
1438
+ <div className="space-y-3">
1439
+ {[
1440
+ {
1441
+ id: "auth-required",
1442
+ label: "Authorization required",
1443
+ checked: true,
1444
+ },
1445
+ {
1446
+ id: "auth-admin",
1447
+ label: "System administrator only",
1448
+ checked: false,
1449
+ },
1450
+ {
1451
+ id: "auth-api",
1452
+ label: "API Key authentication allowed",
1453
+ checked: true,
1454
+ },
1455
+ ].map((item) => (
1456
+ <label
1457
+ key={item.id}
1458
+ className="flex items-center gap-3 cursor-pointer group"
1459
+ >
1460
+ <div
1461
+ 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)]"}`}
1462
+ >
1463
+ {item.checked && (
1464
+ <svg
1465
+ width="10"
1466
+ height="10"
1467
+ viewBox="0 0 24 24"
1468
+ fill="none"
1469
+ stroke="white"
1470
+ strokeWidth="4"
1471
+ >
1472
+ <path d="M20 6L9 17l-5-5" />
1473
+ </svg>
1474
+ )}
1475
+ </div>
1476
+ <span className="text-xs font-medium text-[var(--kyro-text-secondary)] group-hover:text-[var(--kyro-text-primary)] transition-colors">
1477
+ {item.label}
1478
+ </span>
1479
+ </label>
1480
+ ))}
1481
+ </div>
1482
+ </div>
1483
+
1484
+ <div className="pt-6 border-t border-[var(--kyro-border)]">
1485
+ <label className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--kyro-text-secondary)] opacity-50 block mb-2">
1486
+ Usage Help
1487
+ </label>
1488
+ <p className="text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">
1489
+ Include the{" "}
1490
+ <code className="text-[var(--kyro-text-primary)] font-bold">
1491
+ Authorization: Bearer &lt;token&gt;
1492
+ </code>{" "}
1493
+ header to perform write operations on this document.
1494
+ </p>
1495
+ </div>
1496
+ </div>
1497
+ </div>
1498
+ </div>
1499
+ </div>
1500
+ </div>
1501
+ );
1502
+
1503
+ return (
1504
+ <div className="flex flex-col h-full">
1505
+ {layout !== "single" && renderHeader()}
1506
+ <main className="w-full">
1507
+ {view === "edit" && renderEditView()}
1508
+ {view === "version" && renderVersionView()}
1509
+ {view === "api" && renderApiView()}
1510
+ </main>
1511
+ </div>
1512
+ );
816
1513
  }
817
1514
 
818
1515
  interface UploadFieldProps {
@@ -821,6 +1518,7 @@ interface UploadFieldProps {
821
1518
  onChange: (value: any) => void;
822
1519
  disabled?: boolean;
823
1520
  error?: string;
1521
+ documentName?: string;
824
1522
  }
825
1523
 
826
1524
  function UploadField({
@@ -829,6 +1527,7 @@ function UploadField({
829
1527
  onChange,
830
1528
  disabled,
831
1529
  error,
1530
+ documentName,
832
1531
  }: UploadFieldProps) {
833
1532
  const inputRef = useRef<HTMLInputElement>(null);
834
1533
  const [preview, setPreview] = useState<string | null>(null);
@@ -963,33 +1662,33 @@ function RelationshipField({
963
1662
  ? field.relationTo[0]
964
1663
  : field.relationTo;
965
1664
 
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]);
1665
+ const fetchOptions = () => {
1666
+ setLoading(true);
1667
+ fetch(`/api/${targetCollection}?limit=50`)
1668
+ .then((res) => res.json())
1669
+ .then((data) => {
1670
+ setOptions(data.docs || []);
1671
+ setLoading(false);
1672
+ })
1673
+ .catch((err) => {
1674
+ console.error("Failed to fetch relations:", err);
1675
+ setLoading(false);
1676
+ });
1677
+ };
981
1678
 
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
- });
1679
+ useEffect(() => {
1680
+ fetchOptions();
1681
+ }, [targetCollection]);
989
1682
 
990
1683
  const getLabel = (opt: any) => {
991
1684
  if (!opt) return "";
992
- return opt.title || opt.name || opt.filename || opt.slug || opt.id;
1685
+ return (
1686
+ opt.title || opt.name || opt.label || opt.filename || opt.slug || opt.id
1687
+ );
1688
+ };
1689
+
1690
+ const findOptionById = (id: string) => {
1691
+ return options.find((opt) => opt.id === id);
993
1692
  };
994
1693
 
995
1694
  const isSelected = (optId: string) => {
@@ -1018,6 +1717,33 @@ function RelationshipField({
1018
1717
  }
1019
1718
  };
1020
1719
 
1720
+ const renderSelectedValue = () => {
1721
+ if (!value) return null;
1722
+ if (isMultiple && Array.isArray(value)) {
1723
+ if (value.length === 0) return "None selected";
1724
+ return value
1725
+ .map((v) => {
1726
+ const id = v.id || v;
1727
+ const opt = findOptionById(id);
1728
+ return opt ? getLabel(opt) : id;
1729
+ })
1730
+ .join(", ");
1731
+ }
1732
+ const id = value.id || value;
1733
+ const opt = findOptionById(id);
1734
+ return opt ? getLabel(opt) : id;
1735
+ };
1736
+
1737
+ const filteredOptions = search
1738
+ ? (options || []).filter((opt) => {
1739
+ const term = search.toLowerCase();
1740
+ const searchableFields = ["title", "name", "label", "filename", "slug"];
1741
+ return searchableFields.some(
1742
+ (key) => opt[key] && String(opt[key]).toLowerCase().includes(term),
1743
+ );
1744
+ })
1745
+ : options || [];
1746
+
1021
1747
  return (
1022
1748
  <div className="kyro-form-field">
1023
1749
  <label className="kyro-form-label">
@@ -1041,15 +1767,7 @@ function RelationshipField({
1041
1767
 
1042
1768
  <div className="kyro-form-relationship-value">
1043
1769
  {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
- )
1770
+ renderSelectedValue()
1053
1771
  ) : (
1054
1772
  <span className="kyro-form-relationship-empty">
1055
1773
  Click to search and select...
@@ -1099,7 +1817,7 @@ function RelationshipField({
1099
1817
  >
1100
1818
  <span>{getLabel(opt)}</span>
1101
1819
  <span className="kyro-relation-modal-item-id">
1102
- ({opt.id.slice(0, 8)}...)
1820
+ {opt.id ? `(${String(opt.id).slice(0, 8)}...)` : ""}
1103
1821
  </span>
1104
1822
  </button>
1105
1823
  ))
@@ -1121,3 +1839,41 @@ function RelationshipField({
1121
1839
  </div>
1122
1840
  );
1123
1841
  }
1842
+
1843
+ // SEO Utilities
1844
+ function stripHtml(html: string) {
1845
+ if (typeof html !== "string") return "";
1846
+ return html.replace(/<[^>]*>?/gm, "").trim();
1847
+ }
1848
+
1849
+ const SeoPreview = ({
1850
+ title,
1851
+ description,
1852
+ slug,
1853
+ }: {
1854
+ title: string;
1855
+ description: string;
1856
+ slug: string;
1857
+ }) => (
1858
+ <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg p-6 max-w-2xl shadow-sm transition-colors duration-300">
1859
+ <div className="flex items-center gap-2 mb-2">
1860
+ <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)]">
1861
+ K
1862
+ </div>
1863
+ <div className="flex flex-col">
1864
+ <span className="text-sm font-medium text-[var(--kyro-text-primary)] leading-tight">
1865
+ kyro-cms.com
1866
+ </span>
1867
+ <span className="text-[12px] text-[var(--kyro-text-secondary)] leading-tight opacity-80">
1868
+ https://kyro-cms.com › posts › {slug}
1869
+ </span>
1870
+ </div>
1871
+ </div>
1872
+ <h3 className="text-[20px] text-[#2563eb] dark:text-[#60a5fa] font-medium hover:underline cursor-pointer mb-1 leading-tight transition-colors">
1873
+ {title}
1874
+ </h3>
1875
+ <p className="text-[14px] text-[var(--kyro-text-secondary)] leading-relaxed line-clamp-2">
1876
+ {description}
1877
+ </p>
1878
+ </div>
1879
+ );