@kyro-cms/admin 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +5 -3
  2. package/src/components/Admin.tsx +1 -1
  3. package/src/components/AutoForm.tsx +966 -337
  4. package/src/components/CreateView.tsx +1 -1
  5. package/src/components/DetailView.tsx +1 -1
  6. package/src/components/EnhancedListView.tsx +156 -52
  7. package/src/components/ListView.tsx +1 -1
  8. package/src/components/Modal.tsx +65 -8
  9. package/src/components/Sidebar.astro +2 -2
  10. package/src/components/ThemeProvider.tsx +8 -2
  11. package/src/components/blocks/AccordionBlock.tsx +20 -52
  12. package/src/components/blocks/ArrayBlock.tsx +40 -31
  13. package/src/components/blocks/BlockEditModal.tsx +170 -581
  14. package/src/components/blocks/ButtonBlock.tsx +27 -128
  15. package/src/components/blocks/CodeBlock.tsx +88 -40
  16. package/src/components/blocks/ColumnsBlock.tsx +27 -85
  17. package/src/components/blocks/FileBlock.tsx +38 -39
  18. package/src/components/blocks/HeadingBlock.tsx +9 -31
  19. package/src/components/blocks/HeroBlock.tsx +42 -100
  20. package/src/components/blocks/ImageBlock.tsx +6 -7
  21. package/src/components/blocks/LinkBlock.tsx +27 -33
  22. package/src/components/blocks/ListBlock.tsx +47 -26
  23. package/src/components/blocks/RelationshipBlock.tsx +26 -233
  24. package/src/components/blocks/RichTextBlock.tsx +66 -0
  25. package/src/components/blocks/VStackBlock.tsx +23 -37
  26. package/src/components/blocks/VideoBlock.tsx +52 -32
  27. package/src/components/fields/AccordionField.tsx +213 -0
  28. package/src/components/fields/ArrayField.tsx +241 -0
  29. package/src/components/fields/BlocksField.tsx +5 -5
  30. package/src/components/fields/ButtonField.tsx +53 -0
  31. package/src/components/fields/CheckboxField.tsx +7 -3
  32. package/src/components/fields/ChildrenField.tsx +48 -0
  33. package/src/components/fields/CodeField.tsx +154 -94
  34. package/src/components/fields/ColumnsField.tsx +137 -0
  35. package/src/components/fields/DateField.tsx +9 -24
  36. package/src/components/fields/EditorClient.tsx +426 -160
  37. package/src/components/fields/HeadingField.tsx +31 -0
  38. package/src/components/fields/HeroField.tsx +101 -0
  39. package/src/components/fields/JSONField.tsx +7 -27
  40. package/src/components/fields/LinkField.tsx +81 -0
  41. package/src/components/fields/ListField.tsx +74 -0
  42. package/src/components/fields/MarkdownField.tsx +4 -26
  43. package/src/components/fields/NumberField.tsx +9 -27
  44. package/src/components/fields/PortableTextField.tsx +61 -49
  45. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  46. package/src/components/fields/RelationshipField.tsx +59 -13
  47. package/src/components/fields/SelectField.tsx +6 -4
  48. package/src/components/fields/TextField.tsx +9 -24
  49. package/src/components/fields/UploadField.tsx +613 -0
  50. package/src/components/fields/VideoField.tsx +73 -0
  51. package/src/components/fields/extensions/blockComponents.tsx +11 -1
  52. package/src/components/fields/extensions/blocksStore.ts +1 -1
  53. package/src/components/fields/index.ts +12 -1
  54. package/src/components/layout/Layout.tsx +1 -1
  55. package/src/lib/api.ts +163 -0
  56. package/src/lib/config.ts +1 -1
  57. package/src/lib/dataStore.ts +87 -30
  58. package/src/lib/date-utils.ts +69 -0
  59. package/src/lib/db/version-adapter.ts +248 -0
  60. package/src/lib/i18n.tsx +353 -0
  61. package/src/lib/slugify.ts +15 -0
  62. package/src/lib/validation.ts +250 -0
  63. package/src/pages/api/[collection]/[id]/publish.ts +12 -4
  64. package/src/pages/api/[collection]/[id]/versions.ts +39 -9
  65. package/src/pages/api/[collection]/[id].ts +13 -1
  66. package/src/pages/api/[collection]/index.ts +5 -6
  67. package/src/styles/main.css +12 -2
  68. package/src/components/blocks/BlockEditModal.MARKER +0 -12
  69. package/src/components/fields/FileField.tsx +0 -390
  70. package/src/components/fields/HybridContentField.tsx +0 -109
  71. package/src/components/fields/ImageField.tsx +0 -429
@@ -1,13 +1,27 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React from "react";
2
+ import { ChevronRight } from "lucide-react";
2
3
  import {
3
4
  useBlockById,
4
5
  useBlockActions,
5
6
  } from "../fields/extensions/blocksStore";
6
7
  import { SlidePanel } from "../ui/SlidePanel";
7
8
  import { ChildBlocksTree } from "./ChildBlocksTree";
8
- import { ImageField } from "../fields/ImageField";
9
+ import { UploadField } from "../fields/UploadField";
9
10
  import PortableTextField from "../fields/PortableTextField";
10
- import { FileField } from "../fields/FileField";
11
+ import {
12
+ CodeField,
13
+ LinkField,
14
+ AccordionField,
15
+ ButtonField,
16
+ HeadingField,
17
+ VideoField,
18
+ ListField,
19
+ HeroField,
20
+ ArrayField,
21
+ ChildrenField,
22
+ ColumnsField,
23
+ RelationshipBlockField,
24
+ } from "../fields";
11
25
 
12
26
  interface BlockEditModalProps {
13
27
  block: any;
@@ -25,20 +39,6 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
25
39
  const { updateBlock } = useBlockActions();
26
40
  const data = blockData?.data || block.data || {};
27
41
  const children = blockData?.children || block.children || [];
28
- const [collections, setCollections] = useState<string[]>([]);
29
- const [loadingCollections, setLoadingCollections] = useState(true);
30
-
31
- useEffect(() => {
32
- fetch("/api/collections", { credentials: "include" })
33
- .then((res) => res.json())
34
- .then((data) => {
35
- setCollections(
36
- (data.collections || []).map((c: any) => c.slug || c.name || c),
37
- );
38
- setLoadingCollections(false);
39
- })
40
- .catch(() => setLoadingCollections(false));
41
- }, []);
42
42
 
43
43
  const handleChange = (field: string, value: any) => {
44
44
  updateBlock(block.id, { data: { ...data, [field]: value } });
@@ -64,65 +64,33 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
64
64
  const renderFields = () => {
65
65
  switch (block.type) {
66
66
  case "heading":
67
+ return (
68
+ <HeadingField
69
+ text={data.text || ""}
70
+ onChange={handleChange}
71
+ compact
72
+ />
73
+ );
74
+
75
+ case "paragraph":
67
76
  return (
68
77
  <div className="space-y-3">
69
- <div className="flex items-center gap-2">
70
- <span className="text-xs font-medium text-[var(--kyro-text-muted)]">
71
- Level:
72
- </span>
73
- <div className="flex items-center gap-1 bg-[var(--kyro-surface-accent)] rounded-lg p-0.5">
74
- {[1, 2, 3].map((level) => (
75
- <button
76
- key={level}
77
- type="button"
78
- onClick={() => handleChange("level", level)}
79
- className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
80
- (data.level || 1) === level
81
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-sm"
82
- : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
83
- }`}
84
- >
85
- H{level}
86
- </button>
87
- ))}
88
- </div>
89
- </div>
90
- <div>
91
- <input
92
- type="text"
93
- value={data.text || ""}
94
- onChange={(e) => handleChange("text", e.target.value)}
95
- className="w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
96
- placeholder="Enter heading text..."
97
- />
98
- {data.text && (
99
- <div
100
- className="mt-3 p-3 bg-[var(--kyro-surface)] rounded-lg border border-[var(--kyro-border)]"
101
- style={{
102
- fontSize:
103
- data.level === 1
104
- ? "1.75rem"
105
- : data.level === 2
106
- ? "1.5rem"
107
- : "1.25rem",
108
- fontWeight: 700,
109
- }}
110
- >
111
- {data.text}
112
- </div>
113
- )}
114
- </div>
78
+ <textarea
79
+ value={data.text || ""}
80
+ onChange={(e) => handleChange("text", e.target.value)}
81
+ className="w-full px-3 py-3 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-primary)] text-sm min-h-[150px] resize-y focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
82
+ placeholder="Enter paragraph text..."
83
+ />
115
84
  </div>
116
85
  );
117
86
 
118
- case "paragraph":
87
+ case "richtext":
119
88
  return (
120
89
  <div className="space-y-3">
121
90
  <PortableTextField
122
- field={{ name: "paragraph", label: "Content" }}
123
- value={data.text}
124
- onChange={(value) => handleChange("text", value)}
125
- client:only="react"
91
+ field={{ name: "richtext", label: "Content" }}
92
+ value={data.content}
93
+ onChange={(value: any) => handleChange("content", value)}
126
94
  />
127
95
  </div>
128
96
  );
@@ -130,7 +98,7 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
130
98
  case "image":
131
99
  return (
132
100
  <div className="space-y-4">
133
- <ImageField
101
+ <UploadField
134
102
  field={{ label: "Image", name: "image", maxCount: 1 }}
135
103
  value={data.src}
136
104
  onChange={(value) => handleChange("src", value)}
@@ -152,151 +120,43 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
152
120
 
153
121
  case "video":
154
122
  return (
155
- <div className="space-y-4">
156
- <div>
157
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
158
- Video URL
159
- </label>
160
- <input
161
- type="url"
162
- value={data.src || ""}
163
- onChange={(e) => handleChange("src", e.target.value)}
164
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
165
- placeholder="MP4 or YouTube or Vimeo URL..."
166
- />
167
- </div>
168
- </div>
123
+ <VideoField
124
+ src={data.src || ""}
125
+ title={data.title || ""}
126
+ onChange={handleChange}
127
+ compact
128
+ />
169
129
  );
170
130
 
171
131
  case "link":
172
132
  return (
173
- <div className="space-y-4">
174
- <div>
175
- <input
176
- type="text"
177
- value={data.text || ""}
178
- onChange={(e) => handleChange("text", e.target.value)}
179
- className="w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
180
- placeholder="Link text..."
181
- />
182
- </div>
183
- <div>
184
- <input
185
- type="url"
186
- value={data.url || ""}
187
- onChange={(e) => handleChange("url", e.target.value)}
188
- className="w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
189
- placeholder="https://..."
190
- />
191
- </div>
192
- {data.url && data.text && (
193
- <div className="pt-2">
194
- <div className="text-xs font-medium text-[var(--kyro-text-muted)] mb-2">
195
- Preview
196
- </div>
197
- <a
198
- href={data.url}
199
- target="_blank"
200
- rel="noopener noreferrer"
201
- className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg font-medium text-sm hover:opacity-90 transition-opacity"
202
- >
203
- {data.text}
204
- <svg
205
- className="w-3.5 h-3.5"
206
- fill="none"
207
- stroke="currentColor"
208
- viewBox="0 0 24 24"
209
- >
210
- <path
211
- strokeLinecap="round"
212
- strokeLinejoin="round"
213
- strokeWidth="2"
214
- d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
215
- />
216
- </svg>
217
- </a>
218
- </div>
219
- )}
133
+ <div className="space-y-3">
134
+ <LinkField
135
+ text={data.text || ""}
136
+ url={data.url || ""}
137
+ onChange={handleChange}
138
+ compact
139
+ />
220
140
  </div>
221
141
  );
222
142
 
223
143
  case "button":
224
144
  return (
225
- <div className="space-y-4">
226
- <div>
227
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
228
- Button Text
229
- </label>
230
- <input
231
- type="text"
232
- value={data.text || "Button"}
233
- onChange={(e) => handleChange("text", e.target.value)}
234
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
235
- />
236
- </div>
237
- <div>
238
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
239
- Link URL
240
- </label>
241
- <input
242
- type="url"
243
- value={data.url || ""}
244
- onChange={(e) => handleChange("url", e.target.value)}
245
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
246
- placeholder="https://..."
247
- />
248
- </div>
249
- </div>
145
+ <ButtonField
146
+ text={data.text || "Button"}
147
+ url={data.url || ""}
148
+ onChange={handleChange}
149
+ compact
150
+ />
250
151
  );
251
152
 
252
153
  case "list":
253
- const listItems = Array.isArray(data.items) ? data.items : [];
254
154
  return (
255
- <div className="space-y-3">
256
- <div className="space-y-2">
257
- {listItems.length === 0 ? (
258
- <div className="text-center py-6 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg">
259
- No items. Type below to add.
260
- </div>
261
- ) : (
262
- <div className="space-y-1">
263
- {listItems.map((item: any, index: number) => (
264
- <div key={index} className="flex items-center gap-2 group">
265
- <span className="text-sm text-[var(--kyro-text-primary)] flex-1">
266
- {item}
267
- </span>
268
- <button
269
- type="button"
270
- onClick={() => {
271
- const newItems = listItems.filter(
272
- (_: any, i: number) => i !== index,
273
- );
274
- handleChange("items", newItems);
275
- }}
276
- className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded text-red-500 transition-opacity"
277
- >
278
- ×
279
- </button>
280
- </div>
281
- ))}
282
- </div>
283
- )}
284
- <input
285
- type="text"
286
- onKeyDown={(e) => {
287
- if (e.key === "Enter") {
288
- const input = e.target as HTMLInputElement;
289
- if (input.value.trim()) {
290
- handleChange("items", [...listItems, input.value.trim()]);
291
- input.value = "";
292
- }
293
- }
294
- }}
295
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
296
- placeholder="Type and press Enter to add..."
297
- />
298
- </div>
299
- </div>
155
+ <ListField
156
+ items={Array.isArray(data.items) ? data.items : []}
157
+ onChange={(items) => handleChange("items", items)}
158
+ compact
159
+ />
300
160
  );
301
161
 
302
162
  case "code":
@@ -310,189 +170,100 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
310
170
  { value: "json", label: "JSON", icon: "📋" },
311
171
  ];
312
172
  return (
313
- <div className="space-y-3">
314
- <div className="flex items-center gap-2">
315
- <span className="text-xs font-medium text-[var(--kyro-text-muted)]">
316
- Language:
317
- </span>
318
- <div className="flex items-center gap-1 bg-[var(--kyro-surface-accent)] rounded-lg p-0.5 overflow-x-auto">
319
- {languages.map((lang) => (
320
- <button
321
- key={lang.value}
322
- type="button"
323
- onClick={() => handleChange("language", lang.value)}
324
- className={`px-2 py-1 rounded text-[10px] font-medium whitespace-nowrap transition-all flex items-center gap-1 ${
325
- (data.language || "plaintext") === lang.value
326
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-sm"
327
- : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
328
- }`}
173
+ <div className="space-y-4">
174
+ <CodeField
175
+ field={{
176
+ type: "code",
177
+ name: "code",
178
+ label: "Snippet",
179
+ language: data.language || "javascript",
180
+ }}
181
+ value={data.code || ""}
182
+ onChange={(val) => handleChange("code", val)}
183
+ />
184
+
185
+ <div className="grid grid-cols-2 gap-3">
186
+ <div>
187
+ <label className="text-[10px] font-bold uppercase tracking-widest text-[var(--kyro-text-muted)] mb-1.5 block">
188
+ Language
189
+ </label>
190
+ <div className="relative">
191
+ <select
192
+ value={data.language || "javascript"}
193
+ onChange={(e) => handleChange("language", e.target.value)}
194
+ className="w-full pl-3 pr-10 py-2.5 bg-[var(--kyro-bg-secondary)] border border-[var(--kyro-border)] rounded-xl text-xs font-bold text-[var(--kyro-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-primary)]/20 transition-all appearance-none cursor-pointer"
329
195
  >
330
- <span className="text-xs">{lang.icon}</span>
331
- {lang.label}
332
- </button>
333
- ))}
196
+ <option value="plaintext">Plain Text</option>
197
+ <option value="javascript">JS</option>
198
+ <option value="typescript">TS</option>
199
+ <option value="python">PY</option>
200
+ <option value="html">HTML</option>
201
+ <option value="css">CSS</option>
202
+ <option value="json">JSON</option>
203
+ <option value="rust">Rust</option>
204
+ </select>
205
+ <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-[var(--kyro-text-muted)]">
206
+ <ChevronRight className="w-4 h-4 rotate-90" />
207
+ </div>
208
+ </div>
334
209
  </div>
335
210
  </div>
336
- <div>
337
- <textarea
338
- value={data.code || ""}
339
- onChange={(e) => handleChange("code", e.target.value)}
340
- placeholder="Paste or type your code here..."
341
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[160px] font-mono text-[var(--kyro-code-text)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
342
- spellCheck={false}
343
- />
344
- </div>
345
211
  </div>
346
212
  );
347
213
 
348
214
  case "file":
349
215
  return (
350
- <div className="space-y-4">
351
- <FileField
352
- field={{ label: "File", name: "file", maxCount: 1 }}
353
- value={data.file}
354
- onChange={(value) => handleChange("file", value)}
355
- />
356
- </div>
216
+ <UploadField
217
+ field={{ label: "File", name: "file", maxCount: 1 }}
218
+ value={data.file}
219
+ onChange={(value) => handleChange("file", value)}
220
+ />
357
221
  );
358
222
 
359
223
  case "relationship":
360
224
  return (
361
- <div className="space-y-4">
362
- <div>
363
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
364
- Target Collection
365
- </label>
366
- {loadingCollections ? (
367
- <div className="w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-muted)]">
368
- Loading collections...
369
- </div>
370
- ) : (
371
- <select
372
- value={data.relationTo || ""}
373
- onChange={(e) => handleChange("relationTo", e.target.value)}
374
- className="w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
375
- >
376
- <option value="">Select collection...</option>
377
- {collections.map((col) => (
378
- <option key={col} value={col}>
379
- {col}
380
- </option>
381
- ))}
382
- </select>
383
- )}
384
- </div>
385
- <div>
386
- <label className="flex items-center gap-2 cursor-pointer">
387
- <input
388
- type="checkbox"
389
- checked={data.hasMany || false}
390
- onChange={(e) => handleChange("hasMany", e.target.checked)}
391
- className="w-4 h-4 rounded border-[var(--kyro-border)] focus:ring-[var(--kyro-sidebar-active)] focus:ring-offset-0"
392
- />
393
- <span className="text-sm text-[var(--kyro-text-primary)]">
394
- Allow multiple selections
395
- </span>
396
- </label>
397
- </div>
398
- <div>
399
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
400
- Label Field
401
- </label>
402
- <input
403
- type="text"
404
- value={data.labelField || "title"}
405
- onChange={(e) => handleChange("labelField", e.target.value)}
406
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
407
- placeholder="title, name, label..."
408
- />
409
- </div>
410
- </div>
225
+ <RelationshipBlockField
226
+ relationTo={data.relationTo || "pages"}
227
+ hasMany={data.hasMany || false}
228
+ selectedIds={
229
+ Array.isArray(data.selectedIds) ? data.selectedIds : []
230
+ }
231
+ selectedId={data.selectedId}
232
+ labelField={data.labelField || "title"}
233
+ onChange={handleChange}
234
+ />
411
235
  );
412
236
 
413
237
  case "hero":
414
238
  return (
415
- <div className="space-y-4">
416
- <div>
417
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
418
- Heading
419
- </label>
420
- <input
421
- type="text"
422
- value={data.heading || ""}
423
- onChange={(e) => handleChange("heading", e.target.value)}
424
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
425
- placeholder="Hero heading..."
239
+ <div className="space-y-3">
240
+ <HeroField
241
+ heading={data.heading || ""}
242
+ subheading={data.subheading || ""}
243
+ ctaText={data.ctaText || ""}
244
+ ctaUrl={data.ctaUrl || ""}
245
+ onChange={handleChange}
246
+ compact
247
+ />
248
+
249
+ <div className="grid grid-cols-2 gap-2">
250
+ <UploadField
251
+ field={{ label: "Background", name: "bgImage", maxCount: 1 }}
252
+ value={data.bgImage}
253
+ onChange={(v) => handleChange("bgImage", v)}
426
254
  />
427
- </div>
428
- <div>
429
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
430
- Subheading
431
- </label>
432
- <textarea
433
- value={data.subheading || ""}
434
- onChange={(e) => handleChange("subheading", e.target.value)}
435
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[80px] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
436
- placeholder="Hero subheading..."
255
+ <input
256
+ type="url"
257
+ value={data.videoUrl || ""}
258
+ onChange={(e) => handleChange("videoUrl", e.target.value)}
259
+ className="w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent font-mono text-xs"
260
+ placeholder="Video URL..."
437
261
  />
438
262
  </div>
439
- <div className="grid grid-cols-2 gap-3">
440
- <div>
441
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
442
- Background Image
443
- </label>
444
- <ImageField
445
- field={{
446
- label: "Background Image",
447
- name: "bgImage",
448
- maxCount: 1,
449
- }}
450
- value={data.bgImage}
451
- onChange={(value) => handleChange("bgImage", value)}
452
- />
453
- </div>
454
- <div>
455
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
456
- Video URL
457
- </label>
458
- <input
459
- type="url"
460
- value={data.videoUrl || ""}
461
- onChange={(e) => handleChange("videoUrl", e.target.value)}
462
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
463
- placeholder="https://youtube.com/..."
464
- />
465
- </div>
466
- </div>
467
- <div className="grid grid-cols-2 gap-3">
468
- <div>
469
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
470
- CTA Text
471
- </label>
472
- <input
473
- type="text"
474
- value={data.ctaText || ""}
475
- onChange={(e) => handleChange("ctaText", e.target.value)}
476
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
477
- placeholder="Button text..."
478
- />
479
- </div>
480
- <div>
481
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
482
- CTA URL
483
- </label>
484
- <input
485
- type="url"
486
- value={data.ctaUrl || ""}
487
- onChange={(e) => handleChange("ctaUrl", e.target.value)}
488
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
489
- placeholder="https://..."
490
- />
491
- </div>
492
- </div>
493
- <div className="pt-4 border-t border-[var(--kyro-border)]">
494
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-2 block">
495
- Children
263
+
264
+ <div className="pt-2 border-t border-[var(--kyro-border)]">
265
+ <label className="text-[10px] font-medium text-[var(--kyro-text-muted)] mb-1.5 block">
266
+ Children ({children.length})
496
267
  </label>
497
268
  <ChildBlocksTree
498
269
  blockId={block.id}
@@ -505,10 +276,15 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
505
276
 
506
277
  case "array":
507
278
  return (
508
- <div className="space-y-4">
509
- <div className="pt-4 border-t border-[var(--kyro-border)]">
510
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-2 block">
511
- Children
279
+ <div className="space-y-3">
280
+ <ArrayField
281
+ items={Array.isArray(data.items) ? data.items : []}
282
+ onChange={(items) => handleChange("items", items)}
283
+ compact
284
+ />
285
+ <div className="pt-2 border-t border-[var(--kyro-border)]">
286
+ <label className="text-[10px] font-medium text-[var(--kyro-text-muted)] mb-1.5 block">
287
+ Children ({children.length})
512
288
  </label>
513
289
  <ChildBlocksTree
514
290
  blockId={block.id}
@@ -521,226 +297,39 @@ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
521
297
 
522
298
  case "accordion":
523
299
  return (
524
- <div className="space-y-4">
525
- <div className="space-y-2">
526
- <label className="text-xs font-medium text-[var(--kyro-text-muted)]">
527
- Accordion Items
528
- </label>
529
- {(() => {
530
- const accordionItems = Array.isArray(data.items)
531
- ? data.items
532
- : [];
533
- if (accordionItems.length === 0) {
534
- return (
535
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg">
536
- No items. Click "Add Item" to create one.
537
- </div>
538
- );
539
- }
540
- return (
541
- <div className="space-y-2">
542
- {accordionItems.map((item: any, index: number) => (
543
- <div
544
- key={index}
545
- className="p-3 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-surface)] group"
546
- >
547
- <div className="flex items-center gap-2 mb-3">
548
- <div className="flex-1">
549
- <input
550
- type="text"
551
- value={item.title || ""}
552
- onChange={(e) => {
553
- const newItems = [...accordionItems];
554
- newItems[index] = {
555
- ...newItems[index],
556
- title: e.target.value,
557
- };
558
- handleChange("items", newItems);
559
- }}
560
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
561
- placeholder="Item title..."
562
- />
563
- </div>
564
- <button
565
- type="button"
566
- onClick={() => {
567
- const newItems = accordionItems.filter(
568
- (_: any, i: number) => i !== index,
569
- );
570
- handleChange("items", newItems);
571
- }}
572
- className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-50 rounded text-red-500 transition-opacity shrink-0"
573
- >
574
- <svg
575
- className="w-4 h-4"
576
- fill="none"
577
- stroke="currentColor"
578
- viewBox="0 0 24 24"
579
- >
580
- <path
581
- strokeLinecap="round"
582
- strokeLinejoin="round"
583
- strokeWidth="2"
584
- d="M6 18L18 6M6 6l12 12"
585
- />
586
- </svg>
587
- </button>
588
- </div>
589
- <div>
590
- <label className="text-[10px] font-medium text-[var(--kyro-text-muted)] mb-1 block">
591
- Content
592
- </label>
593
- <textarea
594
- value={item.content || ""}
595
- onChange={(e) => {
596
- const newItems = [...accordionItems];
597
- newItems[index] = {
598
- ...newItems[index],
599
- content: e.target.value,
600
- };
601
- handleChange("items", newItems);
602
- }}
603
- className="w-full px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[60px] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
604
- placeholder="Item content..."
605
- />
606
- </div>
607
- </div>
608
- ))}
609
- </div>
610
- );
611
- })()}
612
- <button
613
- type="button"
614
- onClick={() => {
615
- const accordionItems = Array.isArray(data.items)
616
- ? data.items
617
- : [];
618
- const newItems = [
619
- ...accordionItems,
620
- { title: `Item ${accordionItems.length + 1}`, content: "" },
621
- ];
622
- handleChange("items", newItems);
623
- }}
624
- className="mt-2 px-3 py-1.5 text-xs rounded border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-sidebar-active)] transition-colors w-full"
625
- >
626
- + Add Item
627
- </button>
628
- </div>
629
- </div>
300
+ <AccordionField
301
+ items={Array.isArray(data.items) ? data.items : []}
302
+ onChange={(items) => handleChange("items", items)}
303
+ compact
304
+ />
630
305
  );
631
306
 
632
307
  case "vstack":
633
308
  return (
634
- <div className="space-y-4">
635
- <div className="pt-4 border-t border-[var(--kyro-border)]">
636
- <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-2 block">
637
- Children
638
- </label>
639
- <ChildBlocksTree
640
- blockId={block.id}
641
- children={children}
642
- onUpdateChildren={handleUpdateChildren}
643
- />
644
- </div>
645
- </div>
309
+ <ChildrenField
310
+ blockId={block.id}
311
+ children={children}
312
+ onUpdateChildren={handleUpdateChildren}
313
+ />
646
314
  );
647
315
 
648
316
  case "columns":
649
- const columns = data.columns || 1;
650
- const columnData = data.columnData || [];
651
317
  return (
652
- <div className="space-y-4">
653
- <div className="flex items-center gap-2">
654
- <span className="text-xs font-medium text-[var(--kyro-text-muted)]">
655
- Columns:
656
- </span>
657
- <div className="flex items-center gap-1">
658
- <button
659
- type="button"
660
- onClick={() =>
661
- handleChange("columns", Math.max(1, columns - 1))
662
- }
663
- disabled={columns <= 1}
664
- className="w-7 h-7 flex items-center justify-center rounded border border-[var(--kyro-border)] hover:border-[var(--kyro-sidebar-active)] hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30 disabled:cursor-not-allowed text-sm"
665
- >
666
-
667
- </button>
668
- <div className="w-8 h-7 flex items-center justify-center bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded text-sm font-medium">
669
- {columns}
670
- </div>
671
- <button
672
- type="button"
673
- onClick={() =>
674
- handleChange("columns", Math.min(6, columns + 1))
675
- }
676
- disabled={columns >= 6}
677
- className="w-7 h-7 flex items-center justify-center rounded border border-[var(--kyro-border)] hover:border-[var(--kyro-sidebar-active)] hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30 disabled:cursor-not-allowed text-sm"
678
- >
679
- +
680
- </button>
681
- </div>
682
- <span className="text-[10px] text-[var(--kyro-text-muted)] ml-auto">
683
- 1-6
684
- </span>
685
- </div>
686
- <div className="pt-4 border-t border-[var(--kyro-border)]">
687
- <div className="flex items-center justify-between mb-4">
688
- <label className="text-xs font-medium text-[var(--kyro-text-muted)]">
689
- Column Children
690
- </label>
691
- <div className="flex gap-1">
692
- {Array.from({ length: columns }, (_, i) => (
693
- <div
694
- key={i}
695
- className={`w-6 h-6 rounded flex items-center justify-center text-[10px] font-medium ${
696
- i === 0
697
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
698
- : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-muted)]"
699
- }`}
700
- >
701
- {i + 1}
702
- </div>
703
- ))}
704
- </div>
705
- </div>
706
- <div className="overflow-x-auto pb-2 -mx-2 px-2">
707
- <div
708
- className={`grid gap-3`}
709
- style={{
710
- gridTemplateColumns: `repeat(${columns}, minmax(220px, 1fr))`,
711
- }}
712
- >
713
- {Array.from({ length: columns }, (_, i) => (
714
- <div
715
- key={i}
716
- className="border-2 border-dashed border-[var(--kyro-border)] rounded-lg p-3 bg-[var(--kyro-bg-secondary)]/50 hover:border-[var(--kyro-sidebar-active)]/50 transition-colors"
717
- >
718
- <div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--kyro-border)]">
719
- <div className="w-5 h-5 rounded bg-[var(--kyro-sidebar-active)]/10 flex items-center justify-center">
720
- <span className="text-[10px] font-bold text-[var(--kyro-sidebar-active)]">
721
- {i + 1}
722
- </span>
723
- </div>
724
- <span className="text-xs font-medium text-[var(--kyro-text-primary)]">
725
- Column {i + 1}
726
- </span>
727
- <span className="text-[10px] text-[var(--kyro-text-muted)] ml-auto">
728
- {columnData[i]?.children?.length || 0} blocks
729
- </span>
730
- </div>
731
- <ChildBlocksTree
732
- blockId={`${block.id}-col-${i}`}
733
- children={columnData[i]?.children || []}
734
- onUpdateChildren={(newChildren) =>
735
- handleUpdateColumnChildren(i, newChildren)
736
- }
737
- />
738
- </div>
739
- ))}
740
- </div>
741
- </div>
742
- </div>
743
- </div>
318
+ <ColumnsField
319
+ columns={data.columns || 2}
320
+ columnData={data.columnData || []}
321
+ onColumnsChange={(c) => {
322
+ const columnData = data.columnData || [];
323
+ const newColumnData = Array.from({ length: c }, (_, i) => ({
324
+ id: i,
325
+ children: columnData[i]?.children || [],
326
+ }));
327
+ updateBlock(block.id, {
328
+ data: { ...data, columns: c, columnData: newColumnData },
329
+ });
330
+ }}
331
+ onUpdateColumnChildren={handleUpdateColumnChildren}
332
+ />
744
333
  );
745
334
 
746
335
  default: