@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
@@ -0,0 +1,774 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ useBlockById,
4
+ useBlockActions,
5
+ } from "../fields/extensions/blocksStore";
6
+ import { SlidePanel } from "../ui/SlidePanel";
7
+ import { ChildBlocksTree } from "./ChildBlocksTree";
8
+ import { ImageField } from "../fields/ImageField";
9
+ import PortableTextField from "../fields/PortableTextField";
10
+ import { FileField } from "../fields/FileField";
11
+
12
+ interface BlockEditModalProps {
13
+ block: any;
14
+ onClose: () => void;
15
+ }
16
+
17
+ // @MARKER: BlockEditModal with children - 2026-04-29
18
+ // If you want to revert, check git history for previous version
19
+
20
+ export const BlockEditModal: React.FC<BlockEditModalProps> = ({
21
+ block,
22
+ onClose,
23
+ }) => {
24
+ const blockData = useBlockById(block.id);
25
+ const { updateBlock } = useBlockActions();
26
+ const data = blockData?.data || block.data || {};
27
+ 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
+
43
+ const handleChange = (field: string, value: any) => {
44
+ updateBlock(block.id, { data: { ...data, [field]: value } });
45
+ };
46
+
47
+ const handleUpdateChildren = (newChildren: any[]) => {
48
+ updateBlock(block.id, { children: newChildren });
49
+ };
50
+
51
+ const handleUpdateColumnChildren = (
52
+ columnIndex: number,
53
+ newChildren: any[],
54
+ ) => {
55
+ const columnData = data.columnData || [];
56
+ const newColumnData = [...columnData];
57
+ newColumnData[columnIndex] = {
58
+ ...newColumnData[columnIndex],
59
+ children: newChildren,
60
+ };
61
+ updateBlock(block.id, { data: { ...data, columnData: newColumnData } });
62
+ };
63
+
64
+ const renderFields = () => {
65
+ switch (block.type) {
66
+ case "heading":
67
+ return (
68
+ <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>
115
+ </div>
116
+ );
117
+
118
+ case "paragraph":
119
+ return (
120
+ <div className="space-y-3">
121
+ <PortableTextField
122
+ field={{ name: "paragraph", label: "Content" }}
123
+ value={data.text}
124
+ onChange={(value) => handleChange("text", value)}
125
+ client:only="react"
126
+ />
127
+ </div>
128
+ );
129
+
130
+ case "image":
131
+ return (
132
+ <div className="space-y-4">
133
+ <ImageField
134
+ field={{ label: "Image", name: "image", maxCount: 1 }}
135
+ value={data.src}
136
+ onChange={(value) => handleChange("src", value)}
137
+ />
138
+ <div>
139
+ <label className="text-xs font-medium text-[var(--kyro-text-muted)] mb-1 block">
140
+ Alt Text
141
+ </label>
142
+ <input
143
+ type="text"
144
+ value={data.alt || ""}
145
+ onChange={(e) => handleChange("alt", e.target.value)}
146
+ 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"
147
+ placeholder="Description for accessibility..."
148
+ />
149
+ </div>
150
+ </div>
151
+ );
152
+
153
+ case "video":
154
+ 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>
169
+ );
170
+
171
+ case "link":
172
+ 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
+ )}
220
+ </div>
221
+ );
222
+
223
+ case "button":
224
+ 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>
250
+ );
251
+
252
+ case "list":
253
+ const listItems = Array.isArray(data.items) ? data.items : [];
254
+ 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>
300
+ );
301
+
302
+ case "code":
303
+ const languages = [
304
+ { value: "plaintext", label: "Text", icon: "📄" },
305
+ { value: "javascript", label: "JS", icon: "🟨" },
306
+ { value: "typescript", label: "TS", icon: "🔷" },
307
+ { value: "python", label: "PY", icon: "🐍" },
308
+ { value: "html", label: "HTML", icon: "🌐" },
309
+ { value: "css", label: "CSS", icon: "🎨" },
310
+ { value: "json", label: "JSON", icon: "📋" },
311
+ ];
312
+ 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
+ }`}
329
+ >
330
+ <span className="text-xs">{lang.icon}</span>
331
+ {lang.label}
332
+ </button>
333
+ ))}
334
+ </div>
335
+ </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
+ </div>
346
+ );
347
+
348
+ case "file":
349
+ 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>
357
+ );
358
+
359
+ case "relationship":
360
+ 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>
411
+ );
412
+
413
+ case "hero":
414
+ 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..."
426
+ />
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..."
437
+ />
438
+ </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
496
+ </label>
497
+ <ChildBlocksTree
498
+ blockId={block.id}
499
+ children={children}
500
+ onUpdateChildren={handleUpdateChildren}
501
+ />
502
+ </div>
503
+ </div>
504
+ );
505
+
506
+ case "array":
507
+ 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
512
+ </label>
513
+ <ChildBlocksTree
514
+ blockId={block.id}
515
+ children={children}
516
+ onUpdateChildren={handleUpdateChildren}
517
+ />
518
+ </div>
519
+ </div>
520
+ );
521
+
522
+ case "accordion":
523
+ 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>
630
+ );
631
+
632
+ case "vstack":
633
+ 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>
646
+ );
647
+
648
+ case "columns":
649
+ const columns = data.columns || 1;
650
+ const columnData = data.columnData || [];
651
+ 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>
744
+ );
745
+
746
+ default:
747
+ return (
748
+ <div className="text-center py-8 text-[var(--kyro-text-muted)]">
749
+ No editor for "{block.type}"
750
+ </div>
751
+ );
752
+ }
753
+ };
754
+
755
+ return (
756
+ <SlidePanel
757
+ open={true}
758
+ onClose={onClose}
759
+ title={`Edit ${block.type}`}
760
+ width="xl"
761
+ >
762
+ <div className="space-y-4">{renderFields()}</div>
763
+ <div className="mt-6 pt-4 border-t border-[var(--kyro-border)]">
764
+ <button
765
+ type="button"
766
+ onClick={onClose}
767
+ className="w-full py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg font-medium hover:opacity-90 transition-opacity"
768
+ >
769
+ Done
770
+ </button>
771
+ </div>
772
+ </SlidePanel>
773
+ );
774
+ };