@kyro-cms/admin 0.1.7 → 0.1.9

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 +7 -2
  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,429 +0,0 @@
1
- import React, { useState, useEffect, useRef } from "react";
2
-
3
- interface ImageFieldProps {
4
- field: any;
5
- value: any;
6
- onChange: (value: any) => void;
7
- disabled?: boolean;
8
- }
9
-
10
- interface MediaItem {
11
- id: string;
12
- filename: string;
13
- url: string;
14
- thumbnailUrl?: string;
15
- mimeType: string;
16
- title?: string;
17
- folder?: string;
18
- }
19
-
20
- interface MediaFolder {
21
- name: string;
22
- path: string;
23
- }
24
-
25
- export function ImageField({
26
- field,
27
- value,
28
- onChange,
29
- disabled,
30
- }: ImageFieldProps) {
31
- const inputRef = useRef<HTMLInputElement>(null);
32
- const urlInputRef = useRef<HTMLInputElement>(null);
33
- const [uploading, setUploading] = useState(false);
34
- const [showPicker, setShowPicker] = useState(false);
35
- const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
36
- const [folders, setFolders] = useState<MediaFolder[]>([]);
37
- const [selectedFolder, setSelectedFolder] = useState<string>("");
38
- const [mediaLoading, setMediaLoading] = useState(false);
39
- const [pickerSearch, setPickerSearch] = useState("");
40
- const [showUrlInput, setShowUrlInput] = useState(false);
41
- const [urlValue, setUrlValue] = useState("");
42
- const [urlError, setUrlError] = useState("");
43
-
44
- const fieldLabel = field?.label || field?.name || "Image";
45
- const maxCount = field.maxCount || 1;
46
- const isMultiple = maxCount > 1;
47
- const currentValue = Array.isArray(value) ? value : value ? [value] : [];
48
- const canAddMore = currentValue.length < maxCount;
49
-
50
- useEffect(() => {
51
- if (showPicker) {
52
- loadFolders();
53
- loadMedia();
54
- }
55
- }, [showPicker, selectedFolder]);
56
-
57
- const loadFolders = async () => {
58
- try {
59
- const resp = await fetch("/api/media/folders?t=" + Date.now(), {
60
- credentials: "include",
61
- });
62
- const result = await resp.json();
63
- setFolders(result.folders || []);
64
- } catch {
65
- setFolders([]);
66
- }
67
- };
68
-
69
- const loadMedia = async () => {
70
- setMediaLoading(true);
71
- try {
72
- let url = `/api/media?limit=60&sortBy=createdAt&sortDir=desc&t=${Date.now()}`;
73
- if (selectedFolder) {
74
- url += "&folder=" + encodeURIComponent(selectedFolder);
75
- }
76
- const resp = await fetch(url, { credentials: "include" });
77
- const result = await resp.json();
78
- setMediaItems(result.docs || []);
79
- } catch {
80
- setMediaItems([]);
81
- } finally {
82
- setMediaLoading(false);
83
- }
84
- };
85
-
86
- const uploadFile = async (file: File) => {
87
- setUploading(true);
88
- try {
89
- const formData = new FormData();
90
- formData.append("file", file);
91
- if (selectedFolder) {
92
- formData.append("folder", selectedFolder);
93
- }
94
- const resp = await fetch("/api/upload", {
95
- method: "POST",
96
- body: formData,
97
- credentials: "include",
98
- });
99
- if (!resp.ok) throw new Error("Upload failed");
100
- const result = await resp.json();
101
- const newImage = {
102
- id: result.id,
103
- filename: result.filename,
104
- originalName: result.originalName ?? file.name,
105
- url: result.url,
106
- mimeType: file.type,
107
- };
108
- if (isMultiple) {
109
- onChange([...currentValue, newImage]);
110
- } else {
111
- onChange(newImage);
112
- }
113
- } catch (err) {
114
- console.error("Upload failed:", err);
115
- } finally {
116
- setUploading(false);
117
- }
118
- };
119
-
120
- const addByUrl = async () => {
121
- const url = urlValue.trim();
122
- if (!url) return;
123
-
124
- setUrlError("");
125
- try {
126
- const resp = await fetch("/api/upload", {
127
- method: "POST",
128
- headers: { "Content-Type": "application/json" },
129
- body: JSON.stringify({ url }),
130
- credentials: "include",
131
- });
132
- if (!resp.ok) {
133
- const data = await resp.json();
134
- throw new Error(data.error || "Failed to add URL");
135
- }
136
- const result = await resp.json();
137
- const originalName = (() => {
138
- try {
139
- return (
140
- new URL(url).pathname.split("/").pop() ||
141
- result.originalName ||
142
- "url-image"
143
- );
144
- } catch {
145
- return result.originalName || "url-image";
146
- }
147
- })();
148
- const newImage = {
149
- id: result.id,
150
- filename: result.filename,
151
- originalName,
152
- url: result.url,
153
- mimeType: result.mimeType || "image/*",
154
- };
155
- if (isMultiple) {
156
- onChange([...currentValue, newImage]);
157
- } else {
158
- onChange(newImage);
159
- }
160
- setUrlValue("");
161
- setShowUrlInput(false);
162
- } catch (err: any) {
163
- setUrlError(err.message || "Invalid URL");
164
- }
165
- };
166
-
167
- const selectFromLibrary = (item: MediaItem) => {
168
- const newImage = {
169
- id: item.id,
170
- filename: item.filename,
171
- url: item.url,
172
- mimeType: item.mimeType,
173
- };
174
- if (isMultiple) {
175
- onChange([...currentValue, newImage]);
176
- } else {
177
- onChange(newImage);
178
- }
179
- setShowPicker(false);
180
- setPickerSearch("");
181
- };
182
-
183
- const removeImage = (index: number) => {
184
- const newValue = [...currentValue];
185
- newValue.splice(index, 1);
186
- onChange(isMultiple ? newValue : newValue[0] || null);
187
- };
188
-
189
- const filteredMedia = mediaItems.filter(
190
- (item) =>
191
- !pickerSearch ||
192
- item.filename?.toLowerCase().includes(pickerSearch.toLowerCase()) ||
193
- item.title?.toLowerCase().includes(pickerSearch.toLowerCase()),
194
- );
195
-
196
- if (uploading) {
197
- return (
198
- <div className="text-xs text-[var(--kyro-text-muted)] p-2">
199
- Uploading...
200
- </div>
201
- );
202
- }
203
-
204
- const renderImagePreview = (img: any, index?: number) => {
205
- const isImage = img?.url?.match(/\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i);
206
- return (
207
- <div
208
- key={index}
209
- className="flex items-center gap-2 p-2 bg-[var(--kyro-surface-accent)] rounded-lg"
210
- >
211
- {isImage && (
212
- <img
213
- src={img.url}
214
- alt={img.filename || "Image"}
215
- className="w-10 h-10 object-cover rounded border border-[var(--kyro-border)]"
216
- />
217
- )}
218
- <div className="flex-1 min-w-0">
219
- <div className="text-xs truncate text-[var(--kyro-text-primary)] overflow-hidden text-ellipsis whitespace-nowrap">
220
- {img?.originalName || img?.filename || "Image"}
221
- </div>
222
- <button
223
- type="button"
224
- onClick={() =>
225
- index !== undefined ? removeImage(index) : onChange(null)
226
- }
227
- className="text-xs text-red-600 hover:text-red-700 bg-transparent border-none cursor-pointer p-0"
228
- >
229
- Remove
230
- </button>
231
- </div>
232
- </div>
233
- );
234
- };
235
-
236
- if (value) {
237
- return (
238
- <div className="space-y-2">
239
- {isMultiple ? (
240
- <div className="grid grid-cols-2 gap-2">
241
- {currentValue.map((img: any, i: number) =>
242
- renderImagePreview(img, i),
243
- )}
244
- {canAddMore && (
245
- <button
246
- type="button"
247
- onClick={() => inputRef.current?.click()}
248
- disabled={disabled}
249
- className="flex items-center justify-center h-12 border-2 border-dashed border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-secondary)] hover:border-[var(--kyro-border-active)] cursor-pointer transition-colors"
250
- >
251
- + Add {fieldLabel}
252
- </button>
253
- )}
254
- </div>
255
- ) : (
256
- renderImagePreview(value)
257
- )}
258
- <input
259
- ref={inputRef}
260
- type="file"
261
- accept="image/*"
262
- onChange={(e) => {
263
- const file = e.target.files?.[0];
264
- if (file) uploadFile(file);
265
- }}
266
- disabled={disabled}
267
- className="hidden"
268
- />
269
- </div>
270
- );
271
- }
272
-
273
- return (
274
- <div className="space-y-2">
275
- <input
276
- ref={inputRef}
277
- type="file"
278
- accept="image/*"
279
- onChange={(e) => {
280
- const file = e.target.files?.[0];
281
- if (file) uploadFile(file);
282
- }}
283
- disabled={disabled}
284
- className="hidden"
285
- />
286
- <div className="flex gap-2 flex-wrap">
287
- <button
288
- type="button"
289
- onClick={() => inputRef.current?.click()}
290
- disabled={disabled}
291
- className="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-border-active)] transition-colors"
292
- >
293
- + Upload {fieldLabel}
294
- </button>
295
- <button
296
- type="button"
297
- onClick={() => setShowPicker(true)}
298
- disabled={disabled}
299
- className="px-3 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
300
- >
301
- Library
302
- </button>
303
- <button
304
- type="button"
305
- onClick={() => setShowUrlInput(!showUrlInput)}
306
- disabled={disabled}
307
- className="px-3 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[var(--kyro-text-secondary)] cursor-pointer hover:border-[var(--kyro-border-active)] transition-colors"
308
- >
309
- URL
310
- </button>
311
- </div>
312
-
313
- {showUrlInput && (
314
- <div className="flex gap-2 items-center">
315
- <input
316
- ref={urlInputRef}
317
- type="url"
318
- placeholder="https://example.com/image.jpg"
319
- value={urlValue}
320
- onChange={(e) => {
321
- setUrlValue(e.target.value);
322
- setUrlError("");
323
- }}
324
- onKeyDown={(e) => e.key === "Enter" && addByUrl()}
325
- disabled={disabled}
326
- className="flex-1 px-2 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)]"
327
- />
328
- <button
329
- type="button"
330
- onClick={addByUrl}
331
- disabled={disabled || !urlValue.trim()}
332
- className="px-3 py-1.5 text-xs rounded bg-[var(--kyro-primary)] text-white cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50"
333
- >
334
- Add
335
- </button>
336
- {urlError && <span className="text-xs text-red-600">{urlError}</span>}
337
- </div>
338
- )}
339
-
340
- {showPicker && (
341
- <div className="absolute z-50 w-[360px] max-h-[400px] overflow-hidden bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg shadow-lg mt-1 flex flex-col">
342
- <div className="p-2 border-b border-[var(--kyro-border)] flex flex-col gap-2">
343
- <input
344
- type="text"
345
- placeholder="Search media..."
346
- value={pickerSearch}
347
- onChange={(e) => setPickerSearch(e.target.value)}
348
- className="w-full px-2 py-1.5 text-xs rounded border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)]"
349
- />
350
- {folders.length > 0 && (
351
- <div className="flex gap-1 flex-wrap">
352
- <button
353
- type="button"
354
- onClick={() => setSelectedFolder("")}
355
- className={`px-2 py-1 text-xs rounded transition-colors ${
356
- selectedFolder === ""
357
- ? "bg-[var(--kyro-primary)] text-white"
358
- : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
359
- }`}
360
- >
361
- All
362
- </button>
363
- {folders.slice(0, 6).map((folder) => (
364
- <button
365
- key={folder.path}
366
- type="button"
367
- onClick={() => setSelectedFolder(folder.path)}
368
- className={`px-2 py-1 text-xs rounded transition-colors ${
369
- selectedFolder === folder.path
370
- ? "bg-[var(--kyro-primary)] text-white"
371
- : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
372
- }`}
373
- >
374
- {folder.name}
375
- </button>
376
- ))}
377
- </div>
378
- )}
379
- </div>
380
- <div className="flex-1 overflow-auto p-2">
381
- {mediaLoading ? (
382
- <div className="text-center py-5 text-xs text-[var(--kyro-text-muted)]">
383
- Loading...
384
- </div>
385
- ) : filteredMedia.length === 0 ? (
386
- <div className="text-center py-5 text-xs text-[var(--kyro-text-muted)]">
387
- No media found
388
- </div>
389
- ) : (
390
- <div className="grid grid-cols-3 gap-1">
391
- {filteredMedia.map((item) => (
392
- <button
393
- key={item.id}
394
- type="button"
395
- onClick={() => selectFromLibrary(item)}
396
- className="border border-[var(--kyro-border)] rounded overflow-hidden cursor-pointer p-0 bg-none hover:border-[var(--kyro-primary)] transition-colors relative group"
397
- >
398
- <img
399
- src={item.thumbnailUrl || item.url}
400
- alt={item.filename}
401
- className="w-full h-[80px] object-cover"
402
- />
403
- <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
404
- <span className="text-white text-xs px-1 text-center truncate">
405
- {item.filename}
406
- </span>
407
- </div>
408
- </button>
409
- ))}
410
- </div>
411
- )}
412
- </div>
413
- <div className="p-2 border-t border-[var(--kyro-border)] flex justify-between items-center">
414
- <span className="text-xs text-[var(--kyro-text-muted)]">
415
- {filteredMedia.length} items
416
- </span>
417
- <button
418
- type="button"
419
- onClick={() => setShowPicker(false)}
420
- className="text-xs text-[var(--kyro-text-secondary)] bg-transparent border-none cursor-pointer hover:text-[var(--kyro-text-primary)]"
421
- >
422
- Close
423
- </button>
424
- </div>
425
- </div>
426
- )}
427
- </div>
428
- );
429
- }