@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
@@ -0,0 +1,613 @@
1
+ import React, { useState, useEffect, useRef, useMemo } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Image, Film, FileText, Music, File, X, Loader2 } from "lucide-react";
4
+
5
+ interface UploadFieldProps {
6
+ field: any;
7
+ value: any;
8
+ onChange: (value: any) => void;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ interface MediaItem {
13
+ id: string;
14
+ filename: string;
15
+ url: string;
16
+ thumbnailUrl?: string;
17
+ mimeType: string;
18
+ title?: string;
19
+ folder?: string;
20
+ }
21
+
22
+ interface MediaFolder {
23
+ name: string;
24
+ path: string;
25
+ }
26
+
27
+ const getFileType = (mimeType?: string, filename?: string) => {
28
+ const mime = mimeType?.toLowerCase() || "";
29
+ const name = filename?.toLowerCase() || "";
30
+
31
+ if (
32
+ mime.startsWith("image/") ||
33
+ name.match(/\.(jpe?g|png|gif|webp|avif|svg)$/i)
34
+ )
35
+ return "image";
36
+ if (mime.startsWith("video/") || name.match(/\.(mp4|webm|ogg|mov)$/i))
37
+ return "video";
38
+ if (mime.startsWith("audio/") || name.match(/\.(mp3|wav|ogg|m4a)$/i))
39
+ return "audio";
40
+ if (mime.includes("pdf") || name.endsWith(".pdf")) return "pdf";
41
+ if (name.match(/\.(doc|docx|txt|rtf|odt)$/i)) return "document";
42
+ if (name.match(/\.(xls|xlsx|csv)$/i)) return "spreadsheet";
43
+ if (name.match(/\.(zip|tar|gz|7z|rar)$/i)) return "archive";
44
+
45
+ return "other";
46
+ };
47
+
48
+ const FileIcon = ({
49
+ type,
50
+ className,
51
+ }: {
52
+ type: string;
53
+ className?: string;
54
+ }) => {
55
+ switch (type) {
56
+ case "image":
57
+ return <Image className={className} />;
58
+ case "video":
59
+ return <Film className={className} />;
60
+ case "audio":
61
+ return <Music className={className} />;
62
+ case "pdf":
63
+ case "document":
64
+ return <FileText className={className} />;
65
+ default:
66
+ return <File className={className} />;
67
+ }
68
+ };
69
+
70
+ export function UploadField({
71
+ field,
72
+ value,
73
+ onChange,
74
+ disabled,
75
+ }: UploadFieldProps) {
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+ const urlInputRef = useRef<HTMLInputElement>(null);
78
+ const [uploading, setUploading] = useState(false);
79
+ const [showPicker, setShowPicker] = useState(false);
80
+ const [isPickerFullscreen, setIsPickerFullscreen] = useState(false);
81
+ const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
82
+ const [folders, setFolders] = useState<MediaFolder[]>([]);
83
+ const [selectedFolder, setSelectedFolder] = useState<string>("");
84
+ const [mediaLoading, setMediaLoading] = useState(false);
85
+ const [pickerSearch, setPickerSearch] = useState("");
86
+ const [showUrlInput, setShowUrlInput] = useState(false);
87
+ const [urlValue, setUrlValue] = useState("");
88
+ const [urlError, setUrlError] = useState("");
89
+
90
+ const fieldLabel = field?.label || field?.name || "File";
91
+ const maxCount = field.maxCount || 1;
92
+ const isMultiple = maxCount > 1;
93
+ const currentValue = Array.isArray(value) ? value : value ? [value] : [];
94
+ const canAddMore = currentValue.length < maxCount;
95
+
96
+ useEffect(() => {
97
+ if (showPicker) {
98
+ loadFolders();
99
+ loadMedia();
100
+ }
101
+ }, [showPicker, selectedFolder]);
102
+
103
+ const loadFolders = async () => {
104
+ try {
105
+ const resp = await fetch("/api/media/folders?t=" + Date.now(), {
106
+ credentials: "include",
107
+ });
108
+ const result = await resp.json();
109
+ setFolders(result.folders || []);
110
+ } catch {
111
+ setFolders([]);
112
+ }
113
+ };
114
+
115
+ const loadMedia = async () => {
116
+ setMediaLoading(true);
117
+ try {
118
+ let url = `/api/media?limit=60&sortBy=createdAt&sortDir=desc&t=${Date.now()}`;
119
+ if (selectedFolder) {
120
+ url += "&folder=" + encodeURIComponent(selectedFolder);
121
+ }
122
+ const resp = await fetch(url, { credentials: "include" });
123
+ const result = await resp.json();
124
+ setMediaItems(result.docs || []);
125
+ } catch {
126
+ setMediaItems([]);
127
+ } finally {
128
+ setMediaLoading(false);
129
+ }
130
+ };
131
+
132
+ const uploadFile = async (file: File) => {
133
+ setUploading(true);
134
+ try {
135
+ const formData = new FormData();
136
+ formData.append("file", file);
137
+ if (selectedFolder) {
138
+ formData.append("folder", selectedFolder);
139
+ }
140
+ const resp = await fetch("/api/upload", {
141
+ method: "POST",
142
+ body: formData,
143
+ credentials: "include",
144
+ });
145
+ if (!resp.ok) throw new Error("Upload failed");
146
+ const result = await resp.json();
147
+ const newImage = {
148
+ id: result.id,
149
+ filename: result.filename,
150
+ originalName: result.originalName ?? file.name,
151
+ url: result.url,
152
+ mimeType: file.type,
153
+ };
154
+ if (isMultiple) {
155
+ onChange([...currentValue, newImage]);
156
+ } else {
157
+ onChange(newImage);
158
+ }
159
+ } catch (err) {
160
+ console.error("Upload failed:", err);
161
+ } finally {
162
+ setUploading(false);
163
+ }
164
+ };
165
+
166
+ const addByUrl = async () => {
167
+ const url = urlValue.trim();
168
+ if (!url) return;
169
+
170
+ setUrlError("");
171
+ try {
172
+ const resp = await fetch("/api/upload", {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({ url }),
176
+ credentials: "include",
177
+ });
178
+ if (!resp.ok) {
179
+ const data = await resp.json();
180
+ throw new Error(data.error || "Failed to add URL");
181
+ }
182
+ const result = await resp.json();
183
+ const originalName = (() => {
184
+ try {
185
+ return (
186
+ new URL(url).pathname.split("/").pop() ||
187
+ result.originalName ||
188
+ "url-image"
189
+ );
190
+ } catch {
191
+ return result.originalName || "url-image";
192
+ }
193
+ })();
194
+ const newImage = {
195
+ id: result.id,
196
+ filename: result.filename,
197
+ originalName,
198
+ url: result.url,
199
+ mimeType: result.mimeType || "image/*",
200
+ };
201
+ if (isMultiple) {
202
+ onChange([...currentValue, newImage]);
203
+ } else {
204
+ onChange(newImage);
205
+ }
206
+ setUrlValue("");
207
+ setShowUrlInput(false);
208
+ } catch (err: any) {
209
+ setUrlError(err.message || "Invalid URL");
210
+ }
211
+ };
212
+
213
+ const selectFromLibrary = (item: MediaItem) => {
214
+ const newImage = {
215
+ id: item.id,
216
+ filename: item.filename,
217
+ url: item.url,
218
+ mimeType: item.mimeType,
219
+ };
220
+ if (isMultiple) {
221
+ onChange([...currentValue, newImage]);
222
+ } else {
223
+ onChange(newImage);
224
+ }
225
+ setShowPicker(false);
226
+ setPickerSearch("");
227
+ };
228
+
229
+ const removeImage = (index: number) => {
230
+ const newValue = [...currentValue];
231
+ newValue.splice(index, 1);
232
+ onChange(isMultiple ? newValue : newValue[0] || null);
233
+ };
234
+
235
+ const filteredMedia = useMemo(() => {
236
+ return mediaItems.filter((item) => {
237
+ return (
238
+ !pickerSearch ||
239
+ item.filename?.toLowerCase().includes(pickerSearch.toLowerCase()) ||
240
+ item.title?.toLowerCase().includes(pickerSearch.toLowerCase())
241
+ );
242
+ });
243
+ }, [mediaItems, pickerSearch]);
244
+
245
+ if (uploading) {
246
+ return (
247
+ <div className="text-xs text-[var(--kyro-text-muted)] p-2">
248
+ Uploading...
249
+ </div>
250
+ );
251
+ }
252
+
253
+ const renderImagePreview = (img: any, index?: number) => {
254
+ const fileType = getFileType(img?.mimeType, img?.filename || img?.url);
255
+ const isImage = fileType === "image";
256
+
257
+ return (
258
+ <div
259
+ key={index}
260
+ className="flex items-center gap-3 p-2.5 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)] group"
261
+ >
262
+ <div className="w-10 h-10 rounded-md overflow-hidden bg-[var(--kyro-surface)] border border-[var(--kyro-border)] flex items-center justify-center flex-shrink-0">
263
+ {isImage ? (
264
+ <img
265
+ src={img.url}
266
+ alt={img.filename || "Preview"}
267
+ className="w-full h-full object-cover"
268
+ />
269
+ ) : (
270
+ <FileIcon
271
+ type={fileType}
272
+ className="w-5 h-5 text-[var(--kyro-text-secondary)]"
273
+ />
274
+ )}
275
+ </div>
276
+ <div className="flex-1 min-w-0">
277
+ <div className="text-[11px] font-medium truncate text-[var(--kyro-text-primary)]">
278
+ {img?.originalName || img?.filename || "Unnamed File"}
279
+ </div>
280
+ <div className="text-[10px] text-[var(--kyro-text-muted)] uppercase tracking-wider font-bold">
281
+ {fieldLabel}
282
+ </div>
283
+ </div>
284
+ <button
285
+ type="button"
286
+ onClick={() =>
287
+ index !== undefined ? removeImage(index) : onChange(null)
288
+ }
289
+ disabled={disabled}
290
+ className="p-1.5 rounded-md text-[var(--kyro-text-muted)] hover:text-[var(--kyro-error)] hover:bg-[var(--kyro-danger-bg)] transition-all opacity-0 group-hover:opacity-100"
291
+ >
292
+ <X className="w-4 h-4" />
293
+ </button>
294
+ </div>
295
+ );
296
+ };
297
+
298
+ if (value) {
299
+ return (
300
+ <div className="space-y-2">
301
+ {isMultiple ? (
302
+ <div className="grid grid-cols-2 gap-2">
303
+ {currentValue.map((img: any, i: number) =>
304
+ renderImagePreview(img, i),
305
+ )}
306
+ {canAddMore && (
307
+ <button
308
+ type="button"
309
+ onClick={() => inputRef.current?.click()}
310
+ disabled={disabled}
311
+ 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"
312
+ >
313
+ + Add {fieldLabel}
314
+ </button>
315
+ )}
316
+ </div>
317
+ ) : (
318
+ renderImagePreview(value, fieldLabel)
319
+ )}
320
+ <input
321
+ ref={inputRef}
322
+ type="file"
323
+ accept="image/*"
324
+ onChange={(e) => {
325
+ const file = e.target.files?.[0];
326
+ if (file) uploadFile(file);
327
+ }}
328
+ disabled={disabled}
329
+ className="hidden"
330
+ />
331
+ </div>
332
+ );
333
+ }
334
+
335
+ return (
336
+ <div className="space-y-2">
337
+ <input
338
+ ref={inputRef}
339
+ type="file"
340
+ accept={field.allowedTypes?.join(",") || "*/*"}
341
+ onChange={(e) => {
342
+ const file = e.target.files?.[0];
343
+ if (file) uploadFile(file);
344
+ }}
345
+ disabled={disabled}
346
+ className="hidden"
347
+ />
348
+ <div className="flex gap-2 flex-wrap">
349
+ <button
350
+ type="button"
351
+ onClick={() => inputRef.current?.click()}
352
+ disabled={disabled}
353
+ className="px-3 py-1.5 text-xs font-semibold 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"
354
+ >
355
+ + Upload {fieldLabel}
356
+ </button>
357
+ <button
358
+ type="button"
359
+ onClick={() => setShowPicker(true)}
360
+ disabled={disabled}
361
+ className="px-3 py-1.5 text-xs font-semibold 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"
362
+ >
363
+ Library
364
+ </button>
365
+ <button
366
+ type="button"
367
+ onClick={() => setShowUrlInput(!showUrlInput)}
368
+ disabled={disabled}
369
+ className="px-3 py-1.5 text-xs font-semibold 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"
370
+ >
371
+ URL
372
+ </button>
373
+ </div>
374
+
375
+ {showUrlInput && (
376
+ <div className="flex gap-2 items-center">
377
+ <input
378
+ ref={urlInputRef}
379
+ type="url"
380
+ placeholder="https://example.com/image.jpg"
381
+ value={urlValue}
382
+ onChange={(e) => {
383
+ setUrlValue(e.target.value);
384
+ setUrlError("");
385
+ }}
386
+ onKeyDown={(e) => e.key === "Enter" && addByUrl()}
387
+ disabled={disabled}
388
+ 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)]"
389
+ />
390
+ <button
391
+ type="button"
392
+ onClick={addByUrl}
393
+ disabled={disabled || !urlValue.trim()}
394
+ 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"
395
+ >
396
+ Add
397
+ </button>
398
+ {urlError && (
399
+ <span className="text-xs text-[var(--kyro-error)]">{urlError}</span>
400
+ )}
401
+ </div>
402
+ )}
403
+
404
+ {showPicker &&
405
+ (isPickerFullscreen ? (
406
+ createPortal(
407
+ <MediaPickerContent
408
+ isFullscreen
409
+ pickerSearch={pickerSearch}
410
+ setPickerSearch={setPickerSearch}
411
+ folders={folders}
412
+ selectedFolder={selectedFolder}
413
+ setSelectedFolder={setSelectedFolder}
414
+ mediaLoading={mediaLoading}
415
+ filteredMedia={filteredMedia}
416
+ selectFromLibrary={selectFromLibrary}
417
+ setIsPickerFullscreen={setIsPickerFullscreen}
418
+ setShowPicker={setShowPicker}
419
+ />,
420
+ document.body,
421
+ )
422
+ ) : (
423
+ <MediaPickerContent
424
+ isFullscreen={false}
425
+ pickerSearch={pickerSearch}
426
+ setPickerSearch={setPickerSearch}
427
+ folders={folders}
428
+ selectedFolder={selectedFolder}
429
+ setSelectedFolder={setSelectedFolder}
430
+ mediaLoading={mediaLoading}
431
+ filteredMedia={filteredMedia}
432
+ selectFromLibrary={selectFromLibrary}
433
+ setIsPickerFullscreen={setIsPickerFullscreen}
434
+ setShowPicker={setShowPicker}
435
+ />
436
+ ))}
437
+ </div>
438
+ );
439
+ }
440
+
441
+ function MediaPickerContent({
442
+ isFullscreen,
443
+ pickerSearch,
444
+ setPickerSearch,
445
+ folders,
446
+ selectedFolder,
447
+ setSelectedFolder,
448
+ mediaLoading,
449
+ filteredMedia,
450
+ selectFromLibrary,
451
+ setIsPickerFullscreen,
452
+ setShowPicker,
453
+ }: {
454
+ isFullscreen: boolean;
455
+ pickerSearch: string;
456
+ setPickerSearch: (v: string) => void;
457
+ folders: MediaFolder[];
458
+ selectedFolder: string;
459
+ setSelectedFolder: (v: string) => void;
460
+ mediaLoading: boolean;
461
+ filteredMedia: MediaItem[];
462
+ selectFromLibrary: (item: MediaItem) => void;
463
+ setIsPickerFullscreen: (v: boolean) => void;
464
+ setShowPicker: (v: boolean) => void;
465
+ }) {
466
+ return (
467
+ <div
468
+ className={`${isFullscreen
469
+ ? "fixed inset-0 z-[9999]"
470
+ : "absolute z-50 w-[360px] max-h-[400px] mt-1 rounded-lg shadow-lg"
471
+ } overflow-hidden bg-[var(--kyro-surface)] border border-[var(--kyro-border)] flex flex-col`}
472
+ >
473
+ <div className="p-2 border-b border-[var(--kyro-border)] flex flex-col gap-2">
474
+ <input
475
+ type="text"
476
+ placeholder="Search media..."
477
+ value={pickerSearch}
478
+ onChange={(e) => setPickerSearch(e.target.value)}
479
+ 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)]"
480
+ />
481
+ {folders.length > 0 && (
482
+ <div className="flex gap-1 flex-wrap">
483
+ <button
484
+ type="button"
485
+ onClick={() => setSelectedFolder("")}
486
+ className={`px-2 py-1 text-xs rounded transition-colors ${selectedFolder === ""
487
+ ? "bg-[var(--kyro-primary)] text-white"
488
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
489
+ }`}
490
+ >
491
+ All
492
+ </button>
493
+ {folders.slice(0, 6).map((folder) => (
494
+ <button
495
+ key={folder.path}
496
+ type="button"
497
+ onClick={() => setSelectedFolder(folder.path)}
498
+ className={`px-2 py-1 text-xs rounded transition-colors ${selectedFolder === folder.path
499
+ ? "bg-[var(--kyro-primary)] text-white"
500
+ : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-border)]"
501
+ }`}
502
+ >
503
+ {folder.name}
504
+ </button>
505
+ ))}
506
+ </div>
507
+ )}
508
+ </div>
509
+
510
+ {/* Picker Items */}
511
+ <div className="flex-1 overflow-auto p-2">
512
+ {mediaLoading ? (
513
+ <div className="text-center py-5 text-xs text-[var(--kyro-text-muted)]">
514
+ Loading...
515
+ </div>
516
+ ) : filteredMedia.length === 0 ? (
517
+ <div className="text-center py-5 text-xs text-[var(--kyro-text-muted)]">
518
+ No media found
519
+ </div>
520
+ ) : (
521
+ <div
522
+ className={`grid gap-1 ${isFullscreen
523
+ ? "grid-cols-[repeat(auto-fill,minmax(140px,1fr))]"
524
+ : "grid-cols-3"
525
+ }`}
526
+ >
527
+ {filteredMedia.map((item) => (
528
+ <button
529
+ key={item.id}
530
+ type="button"
531
+ onClick={() => selectFromLibrary(item)}
532
+ className="border border-[var(--kyro-border)] rounded-md overflow-hidden cursor-pointer p-0 bg-[var(--kyro-surface)] hover:border-[var(--kyro-primary)] transition-all relative group"
533
+ >
534
+ <div
535
+ className={`w-full flex items-center justify-center bg-[var(--kyro-surface-accent)] ${isFullscreen ? "h-[120px]" : "h-[80px]"
536
+ }`}
537
+ >
538
+ {getFileType(item.mimeType, item.filename) === "image" ? (
539
+ <img
540
+ src={item.thumbnailUrl || item.url}
541
+ alt={item.filename}
542
+ className="w-full h-full object-cover"
543
+ />
544
+ ) : (
545
+ <FileIcon
546
+ type={getFileType(item.mimeType, item.filename)}
547
+ className={isFullscreen ? "w-10 h-10" : "w-8 h-8"}
548
+ />
549
+ )}
550
+ </div>
551
+ <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center p-2">
552
+ <span className="text-white text-[10px] font-medium text-center line-clamp-2 mb-1">
553
+ {item.filename}
554
+ </span>
555
+ <span className="text-white/70 text-[9px] uppercase font-bold tracking-tighter">
556
+ {getFileType(item.mimeType, item.filename)}
557
+ </span>
558
+ </div>
559
+ </button>
560
+ ))}
561
+ </div>
562
+ )}
563
+ </div>
564
+ <div className="p-2 border-t border-[var(--kyro-border)] flex justify-between items-center">
565
+ <span className="text-xs text-[var(--kyro-text-muted)]">
566
+ {filteredMedia.length} items
567
+ </span>
568
+ <div className="flex gap-2 items-center">
569
+ <button
570
+ type="button"
571
+ onClick={() => setIsPickerFullscreen(!isFullscreen)}
572
+ className="p-1.5 rounded text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
573
+ title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
574
+ >
575
+ {isFullscreen ? (
576
+ <svg
577
+ width="14"
578
+ height="14"
579
+ viewBox="0 0 24 24"
580
+ fill="none"
581
+ stroke="currentColor"
582
+ strokeWidth="2"
583
+ >
584
+ <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
585
+ </svg>
586
+ ) : (
587
+ <svg
588
+ width="14"
589
+ height="14"
590
+ viewBox="0 0 24 24"
591
+ fill="none"
592
+ stroke="currentColor"
593
+ strokeWidth="2"
594
+ >
595
+ <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
596
+ </svg>
597
+ )}
598
+ </button>
599
+ <button
600
+ type="button"
601
+ onClick={() => {
602
+ setShowPicker(false);
603
+ setIsPickerFullscreen(false);
604
+ }}
605
+ className="text-xs text-[var(--kyro-text-secondary)] bg-transparent border-none cursor-pointer hover:text-[var(--kyro-text-primary)]"
606
+ >
607
+ Close
608
+ </button>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ );
613
+ }
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+ import { UploadField } from "./UploadField";
3
+
4
+ interface VideoFieldProps {
5
+ src?: string;
6
+ title?: string;
7
+ onChange: (field: string, value: string) => void;
8
+ onUploadChange?: (value: any) => void;
9
+ compact?: boolean;
10
+ }
11
+
12
+ export const VideoField: React.FC<VideoFieldProps> = ({
13
+ src = "",
14
+ title = "",
15
+ onChange,
16
+ onUploadChange,
17
+ compact = false,
18
+ }) => {
19
+ const isExternalUrl =
20
+ src.includes("youtube.com") ||
21
+ src.includes("vimeo.com") ||
22
+ src.includes("youtu.be");
23
+
24
+ if (compact) {
25
+ return (
26
+ <div className="space-y-2">
27
+ <input
28
+ type="url"
29
+ value={src}
30
+ onChange={(e) => onChange("src", e.target.value)}
31
+ 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"
32
+ placeholder="MP4 URL, YouTube, or Vimeo link..."
33
+ />
34
+ <input
35
+ type="text"
36
+ value={title}
37
+ onChange={(e) => onChange("title", e.target.value)}
38
+ 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"
39
+ placeholder="Video title (optional)..."
40
+ />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div className="space-y-3">
47
+ <UploadField
48
+ field={{ label: "Video Asset", name: "src", maxCount: 1 }}
49
+ value={src}
50
+ onChange={onUploadChange || ((v) => onChange("src", v))}
51
+ />
52
+ <span className="text-xs text-[var(--kyro-text-muted)]">
53
+ or paste a URL
54
+ </span>
55
+ <input
56
+ type="url"
57
+ value={src}
58
+ onChange={(e) => onChange("src", e.target.value)}
59
+ 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 font-mono text-xs"
60
+ placeholder="MP4 URL, YouTube, or Vimeo link..."
61
+ />
62
+ <input
63
+ type="text"
64
+ value={title}
65
+ onChange={(e) => onChange("title", e.target.value)}
66
+ 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"
67
+ placeholder="Video title (optional)..."
68
+ />
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default VideoField;