@kyro-cms/admin 0.1.2

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 (102) hide show
  1. package/.astro/content.d.ts +154 -0
  2. package/.astro/settings.json +5 -0
  3. package/.astro/types.d.ts +2 -0
  4. package/astro.config.mjs +28 -0
  5. package/bun.lock +1374 -0
  6. package/dist/client/_astro/AdminLayout.DkDpng53.css +1 -0
  7. package/dist/client/_astro/AutoForm.3eJCmCJp.js +1 -0
  8. package/dist/client/_astro/client.DyczpTbx.js +9 -0
  9. package/dist/client/_astro/index.B02hbnpo.js +1 -0
  10. package/dist/client/fonts/Serotiva-Black.woff2 +0 -0
  11. package/dist/client/fonts/Serotiva-Bold.woff2 +0 -0
  12. package/dist/client/fonts/Serotiva-Medium.woff2 +0 -0
  13. package/dist/client/fonts/Serotiva-Regular.woff2 +0 -0
  14. package/dist/client/fonts/Serotiva-SemiBold.woff2 +0 -0
  15. package/dist/server/chunks/AdminLayout_D-_JeUqC.mjs +26 -0
  16. package/dist/server/chunks/_id__BzI_o0qT.mjs +50 -0
  17. package/dist/server/chunks/_id__Cd-jOuY3.mjs +238 -0
  18. package/dist/server/chunks/_id__DvbD--iR.mjs +992 -0
  19. package/dist/server/chunks/_id__vpVaEo16.mjs +128 -0
  20. package/dist/server/chunks/_virtual_astro_server-island-manifest_CQQ1F5PF.mjs +7 -0
  21. package/dist/server/chunks/_virtual_astro_session-driver_Bk3Q189E.mjs +4 -0
  22. package/dist/server/chunks/astro-component_Dbx3T2Nh.mjs +37 -0
  23. package/dist/server/chunks/audit-logs_DrnUMRvY.mjs +74 -0
  24. package/dist/server/chunks/config_CPXslElD.mjs +4221 -0
  25. package/dist/server/chunks/dataStore_Dl7cA2Qp.mjs +89 -0
  26. package/dist/server/chunks/index_CVqOkerS.mjs +2960 -0
  27. package/dist/server/chunks/index_CX8SQ4BF.mjs +55 -0
  28. package/dist/server/chunks/index_CYofDU51.mjs +58 -0
  29. package/dist/server/chunks/index_DdNRhuaM.mjs +55 -0
  30. package/dist/server/chunks/index_DupPvtIF.mjs +42 -0
  31. package/dist/server/chunks/index_YTS_M-B9.mjs +263 -0
  32. package/dist/server/chunks/index_YeCzuVps.mjs +53 -0
  33. package/dist/server/chunks/login_DLyqMRO8.mjs +93 -0
  34. package/dist/server/chunks/logout_CSbt5wea.mjs +50 -0
  35. package/dist/server/chunks/me_C04jlYhH.mjs +41 -0
  36. package/dist/server/chunks/new_BbQ9b55M.mjs +92 -0
  37. package/dist/server/chunks/node_9bvTewss.mjs +1014 -0
  38. package/dist/server/chunks/noop-entrypoint_BOlrdqWF.mjs +3 -0
  39. package/dist/server/chunks/sequence_9cl7AJy-.mjs +2503 -0
  40. package/dist/server/chunks/server_peBx9VXG.mjs +8117 -0
  41. package/dist/server/chunks/sharp_pmJ7nHES.mjs +142 -0
  42. package/dist/server/chunks/users_Dzddy_YR.mjs +137 -0
  43. package/dist/server/entry.mjs +5 -0
  44. package/dist/server/virtual_astro_middleware.mjs +48 -0
  45. package/package.json +33 -0
  46. package/public/fonts/Serotiva-Black.woff2 +0 -0
  47. package/public/fonts/Serotiva-Bold.woff2 +0 -0
  48. package/public/fonts/Serotiva-Medium.woff2 +0 -0
  49. package/public/fonts/Serotiva-Regular.woff2 +0 -0
  50. package/public/fonts/Serotiva-SemiBold.woff2 +0 -0
  51. package/src/collections/auth/index.ts +155 -0
  52. package/src/components/ActionBar.tsx +215 -0
  53. package/src/components/Admin.tsx +214 -0
  54. package/src/components/AutoForm.tsx +1123 -0
  55. package/src/components/BulkActionsBar.tsx +80 -0
  56. package/src/components/CreateView.tsx +99 -0
  57. package/src/components/DetailView.tsx +329 -0
  58. package/src/components/Icons.tsx +23 -0
  59. package/src/components/ListView.tsx +192 -0
  60. package/src/components/StatusBadge.tsx +76 -0
  61. package/src/components/ThemeProvider.tsx +155 -0
  62. package/src/components/VersionHistoryPanel.tsx +205 -0
  63. package/src/components/fields/CheckboxField.tsx +37 -0
  64. package/src/components/fields/DateField.tsx +42 -0
  65. package/src/components/fields/NumberField.tsx +44 -0
  66. package/src/components/fields/RelationshipField.tsx +87 -0
  67. package/src/components/fields/SelectField.tsx +56 -0
  68. package/src/components/fields/TextField.tsx +49 -0
  69. package/src/components/index.ts +30 -0
  70. package/src/components/layout/Breadcrumbs.tsx +36 -0
  71. package/src/components/layout/Header.tsx +37 -0
  72. package/src/components/layout/Layout.tsx +25 -0
  73. package/src/components/layout/Sidebar.tsx +462 -0
  74. package/src/components/ui/Badge.tsx +14 -0
  75. package/src/components/ui/Button.tsx +41 -0
  76. package/src/components/ui/Dropdown.tsx +82 -0
  77. package/src/components/ui/Modal.tsx +135 -0
  78. package/src/components/ui/SlidePanel.tsx +73 -0
  79. package/src/components/ui/Spinner.tsx +24 -0
  80. package/src/components/ui/Toast.tsx +78 -0
  81. package/src/layouts/AdminLayout.astro +197 -0
  82. package/src/lib/config.ts +68 -0
  83. package/src/lib/dataStore.ts +111 -0
  84. package/src/middleware.ts +48 -0
  85. package/src/pages/[collection]/[id].astro +176 -0
  86. package/src/pages/[collection]/index.astro +180 -0
  87. package/src/pages/api/[collection]/[id].ts +258 -0
  88. package/src/pages/api/[collection]/index.ts +289 -0
  89. package/src/pages/api/auth/[id].ts +142 -0
  90. package/src/pages/api/auth/audit-logs.ts +80 -0
  91. package/src/pages/api/auth/login.ts +101 -0
  92. package/src/pages/api/auth/logout.ts +48 -0
  93. package/src/pages/api/auth/me.ts +36 -0
  94. package/src/pages/api/auth/users.ts +150 -0
  95. package/src/pages/audit/index.astro +110 -0
  96. package/src/pages/index.astro +225 -0
  97. package/src/pages/roles/index.astro +114 -0
  98. package/src/pages/users/[id].astro +174 -0
  99. package/src/pages/users/index.astro +142 -0
  100. package/src/pages/users/new.astro +91 -0
  101. package/src/styles/main.css +1449 -0
  102. package/tsconfig.json +12 -0
@@ -0,0 +1,1123 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import type { CollectionConfig, Field, Block } from "@kyro-cms/core";
3
+
4
+ interface AutoFormProps {
5
+ config: CollectionConfig;
6
+ data?: Record<string, any>;
7
+ errors?: Record<string, string>;
8
+ onChange?: (data: Record<string, any>) => void;
9
+ disabled?: boolean;
10
+ collectionSlug?: string;
11
+ }
12
+
13
+ export function AutoForm({
14
+ config,
15
+ data = {},
16
+ errors = {},
17
+ onChange,
18
+ disabled,
19
+ }: AutoFormProps) {
20
+ const handleFieldChange = (fieldName: string, value: any) => {
21
+ onChange?.({
22
+ ...data,
23
+ [fieldName]: value,
24
+ });
25
+ };
26
+
27
+ const renderField = (field: Field, parentData?: Record<string, any>) => {
28
+ if (field.admin?.hidden) return null;
29
+
30
+ const value =
31
+ parentData !== undefined ? parentData[field.name!] : data[field.name!];
32
+ const error = errors[field.name!];
33
+
34
+ if (field.type === "row" && "fields" in field) {
35
+ return (
36
+ <div
37
+ key={field.name || `row-${Math.random()}`}
38
+ className="kyro-form-row"
39
+ >
40
+ {(field as any).fields.map((f: Field) => renderField(f, parentData))}
41
+ </div>
42
+ );
43
+ }
44
+
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
+ }
72
+
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))}
84
+ </div>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ switch (field.type) {
92
+ case "text":
93
+ case "email":
94
+ 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>
100
+ )}
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>
120
+ )}
121
+ {error && <p className="kyro-form-error">{error}</p>}
122
+ </div>
123
+ );
124
+
125
+ case "password":
126
+ return (
127
+ <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>
132
+ )}
133
+ </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
+ />
142
+ {error && <p className="kyro-form-error">{error}</p>}
143
+ </div>
144
+ );
145
+
146
+ case "textarea":
147
+ return (
148
+ <div key={field.name} className="kyro-form-field">
149
+ <label className="kyro-form-label">
150
+ {field.label || field.name}
151
+ {field.required && (
152
+ <span className="kyro-form-label-required">*</span>
153
+ )}
154
+ </label>
155
+ <textarea
156
+ className={`kyro-form-input kyro-form-textarea ${error ? "kyro-form-input-error" : ""}`}
157
+ value={value || ""}
158
+ onChange={(e) => handleFieldChange(field.name!, e.target.value)}
159
+ disabled={disabled}
160
+ rows={(field as any).rows || 4}
161
+ placeholder={`Enter ${field.label || field.name}`}
162
+ />
163
+ {field.admin?.description && !error && (
164
+ <p className="kyro-form-help">{field.admin.description}</p>
165
+ )}
166
+ {error && <p className="kyro-form-error">{error}</p>}
167
+ </div>
168
+ );
169
+
170
+ case "number":
171
+ return (
172
+ <div key={field.name} className="kyro-form-field">
173
+ <label className="kyro-form-label">
174
+ {field.label || field.name}
175
+ {field.required && (
176
+ <span className="kyro-form-label-required">*</span>
177
+ )}
178
+ </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
+ }
186
+ disabled={disabled}
187
+ placeholder="0"
188
+ min={(field as any).min}
189
+ max={(field as any).max}
190
+ step={(field as any).step || "any"}
191
+ />
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
+ </div>
197
+ );
198
+
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
+ )}
218
+ </div>
219
+ );
220
+
221
+ case "date":
222
+ return (
223
+ <div key={field.name} className="kyro-form-field">
224
+ <label className="kyro-form-label">
225
+ {field.label || field.name}
226
+ {field.required && (
227
+ <span className="kyro-form-label-required">*</span>
228
+ )}
229
+ </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
+ }
240
+ disabled={disabled}
241
+ />
242
+ {error && <p className="kyro-form-error">{error}</p>}
243
+ </div>
244
+ );
245
+
246
+ case "select":
247
+ return (
248
+ <div key={field.name} className="kyro-form-field">
249
+ <label className="kyro-form-label">
250
+ {field.label || field.name}
251
+ {field.required && (
252
+ <span className="kyro-form-label-required">*</span>
253
+ )}
254
+ </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)}
259
+ 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>}
272
+ </div>
273
+ );
274
+
275
+ case "radio":
276
+ return (
277
+ <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>
301
+ {error && <p className="kyro-form-error">{error}</p>}
302
+ </div>
303
+ );
304
+
305
+ case "color":
306
+ return (
307
+ <div key={field.name} className="kyro-form-field">
308
+ <label className="kyro-form-label">
309
+ {field.label || field.name}
310
+ {field.required && (
311
+ <span className="kyro-form-label-required">*</span>
312
+ )}
313
+ </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>
326
+ {error && <p className="kyro-form-error">{error}</p>}
327
+ </div>
328
+ );
329
+
330
+ case "json":
331
+ return (
332
+ <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);
351
+ }
352
+ }}
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>}
361
+ </div>
362
+ );
363
+
364
+ case "markdown":
365
+ 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>
386
+ );
387
+
388
+ case "code":
389
+ 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>
410
+ );
411
+
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
+ }
514
+ });
515
+ handleFieldChange(field.name!, [...items, newItem]);
516
+ }}
517
+ disabled={disabled}
518
+ >
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}
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;
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
+ )}
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>
728
+ </div>
729
+ );
730
+ }
731
+ }
732
+ };
733
+
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":
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
+ }
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
+ }
847
+ onChange({
848
+ filename: file.name,
849
+ size: file.size,
850
+ type: file.type,
851
+ url: URL.createObjectURL(file),
852
+ });
853
+ };
854
+
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
+ };
861
+
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>
868
+ <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()}
877
+ >
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"}
899
+ </span>
900
+ <button
901
+ type="button"
902
+ className="kyro-form-upload-change"
903
+ onClick={(e) => {
904
+ e.stopPropagation();
905
+ inputRef.current?.click();
906
+ }}
907
+ >
908
+ Change
909
+ </button>
910
+ </div>
911
+ </div>
912
+ ) : (
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>
930
+ </div>
931
+ )}
932
+ </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>}
937
+ </div>
938
+ );
939
+ }
940
+
941
+ interface RelationshipFieldProps {
942
+ field: any;
943
+ value: any;
944
+ onChange: (value: any) => void;
945
+ disabled?: boolean;
946
+ error?: string;
947
+ }
948
+
949
+ function RelationshipField({
950
+ field,
951
+ value,
952
+ onChange,
953
+ disabled,
954
+ error,
955
+ }: RelationshipFieldProps) {
956
+ const [isOpen, setIsOpen] = useState(false);
957
+ const [search, setSearch] = useState("");
958
+ const [options, setOptions] = useState<any[]>([]);
959
+ const [loading, setLoading] = useState(false);
960
+
961
+ const isMultiple = field.hasMany;
962
+ const targetCollection = Array.isArray(field.relationTo)
963
+ ? field.relationTo[0]
964
+ : field.relationTo;
965
+
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]);
981
+
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
+ });
989
+
990
+ const getLabel = (opt: any) => {
991
+ if (!opt) return "";
992
+ return opt.title || opt.name || opt.filename || opt.slug || opt.id;
993
+ };
994
+
995
+ const isSelected = (optId: string) => {
996
+ if (!value) return false;
997
+ if (isMultiple) {
998
+ return (value as any[]).some((v) => (v.id || v) === optId);
999
+ }
1000
+ return (value.id || value) === optId;
1001
+ };
1002
+
1003
+ const toggleSelection = (opt: any) => {
1004
+ if (isMultiple) {
1005
+ const current = Array.isArray(value) ? value : [];
1006
+ if (isSelected(opt.id)) {
1007
+ onChange(current.filter((item) => (item.id || item) !== opt.id));
1008
+ } else {
1009
+ onChange([...current, opt.id]);
1010
+ }
1011
+ } else {
1012
+ if (isSelected(opt.id)) {
1013
+ onChange(null);
1014
+ } else {
1015
+ onChange(opt.id);
1016
+ setIsOpen(false);
1017
+ }
1018
+ }
1019
+ };
1020
+
1021
+ return (
1022
+ <div className="kyro-form-field">
1023
+ <label className="kyro-form-label">
1024
+ {field.label || field.name}
1025
+ {field.required && <span className="kyro-form-label-required">*</span>}
1026
+ </label>
1027
+
1028
+ <div
1029
+ className="kyro-form-relationship"
1030
+ onClick={() => !disabled && setIsOpen(true)}
1031
+ style={disabled ? { opacity: 0.5, cursor: "not-allowed" } : {}}
1032
+ >
1033
+ <div className="kyro-form-relationship-header">
1034
+ <span className="kyro-form-relationship-type">
1035
+ {targetCollection}
1036
+ </span>
1037
+ {isMultiple && (
1038
+ <span className="kyro-form-relationship-badge">Multiple</span>
1039
+ )}
1040
+ </div>
1041
+
1042
+ <div className="kyro-form-relationship-value">
1043
+ {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
+ )
1053
+ ) : (
1054
+ <span className="kyro-form-relationship-empty">
1055
+ Click to search and select...
1056
+ </span>
1057
+ )}
1058
+ </div>
1059
+ </div>
1060
+
1061
+ {field.admin?.description && !error && (
1062
+ <p className="kyro-form-help">{field.admin.description}</p>
1063
+ )}
1064
+ {error && <p className="kyro-form-error">{error}</p>}
1065
+
1066
+ {/* Modal */}
1067
+ {isOpen && (
1068
+ <div className="kyro-modal-overlay" onClick={() => setIsOpen(false)}>
1069
+ <div
1070
+ className="kyro-relation-modal"
1071
+ onClick={(e) => e.stopPropagation()}
1072
+ >
1073
+ <div className="kyro-relation-modal-header">
1074
+ <h3>Select {field.label || field.name}</h3>
1075
+ <input
1076
+ type="text"
1077
+ autoFocus
1078
+ placeholder={`Search in ${targetCollection}...`}
1079
+ className="kyro-relation-modal-search"
1080
+ value={search}
1081
+ onChange={(e) => setSearch(e.target.value)}
1082
+ />
1083
+ </div>
1084
+
1085
+ <div className="kyro-relation-modal-list">
1086
+ {loading ? (
1087
+ <div className="kyro-relation-modal-empty">Loading...</div>
1088
+ ) : filteredOptions.length === 0 ? (
1089
+ <div className="kyro-relation-modal-empty">
1090
+ No results found.
1091
+ </div>
1092
+ ) : (
1093
+ filteredOptions.map((opt) => (
1094
+ <button
1095
+ key={opt.id}
1096
+ type="button"
1097
+ className={`kyro-relation-modal-item ${isSelected(opt.id) ? "selected" : ""}`}
1098
+ onClick={() => toggleSelection(opt)}
1099
+ >
1100
+ <span>{getLabel(opt)}</span>
1101
+ <span className="kyro-relation-modal-item-id">
1102
+ ({opt.id.slice(0, 8)}...)
1103
+ </span>
1104
+ </button>
1105
+ ))
1106
+ )}
1107
+ </div>
1108
+
1109
+ <div className="kyro-relation-modal-footer">
1110
+ <button
1111
+ type="button"
1112
+ className="kyro-btn kyro-btn-secondary kyro-btn-sm"
1113
+ onClick={() => setIsOpen(false)}
1114
+ >
1115
+ Done
1116
+ </button>
1117
+ </div>
1118
+ </div>
1119
+ </div>
1120
+ )}
1121
+ </div>
1122
+ );
1123
+ }