@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.
- package/package.json +5 -3
- package/src/components/Admin.tsx +1 -1
- package/src/components/AutoForm.tsx +966 -337
- package/src/components/CreateView.tsx +1 -1
- package/src/components/DetailView.tsx +1 -1
- package/src/components/EnhancedListView.tsx +156 -52
- package/src/components/ListView.tsx +1 -1
- package/src/components/Modal.tsx +65 -8
- package/src/components/Sidebar.astro +2 -2
- package/src/components/ThemeProvider.tsx +8 -2
- package/src/components/blocks/AccordionBlock.tsx +20 -52
- package/src/components/blocks/ArrayBlock.tsx +40 -31
- package/src/components/blocks/BlockEditModal.tsx +170 -581
- package/src/components/blocks/ButtonBlock.tsx +27 -128
- package/src/components/blocks/CodeBlock.tsx +88 -40
- package/src/components/blocks/ColumnsBlock.tsx +27 -85
- package/src/components/blocks/FileBlock.tsx +38 -39
- package/src/components/blocks/HeadingBlock.tsx +9 -31
- package/src/components/blocks/HeroBlock.tsx +42 -100
- package/src/components/blocks/ImageBlock.tsx +6 -7
- package/src/components/blocks/LinkBlock.tsx +27 -33
- package/src/components/blocks/ListBlock.tsx +47 -26
- package/src/components/blocks/RelationshipBlock.tsx +26 -233
- package/src/components/blocks/RichTextBlock.tsx +66 -0
- package/src/components/blocks/VStackBlock.tsx +23 -37
- package/src/components/blocks/VideoBlock.tsx +52 -32
- package/src/components/fields/AccordionField.tsx +213 -0
- package/src/components/fields/ArrayField.tsx +241 -0
- package/src/components/fields/BlocksField.tsx +5 -5
- package/src/components/fields/ButtonField.tsx +53 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/ChildrenField.tsx +48 -0
- package/src/components/fields/CodeField.tsx +154 -94
- package/src/components/fields/ColumnsField.tsx +137 -0
- package/src/components/fields/DateField.tsx +9 -24
- package/src/components/fields/EditorClient.tsx +426 -160
- package/src/components/fields/HeadingField.tsx +31 -0
- package/src/components/fields/HeroField.tsx +101 -0
- package/src/components/fields/JSONField.tsx +7 -27
- package/src/components/fields/LinkField.tsx +81 -0
- package/src/components/fields/ListField.tsx +74 -0
- package/src/components/fields/MarkdownField.tsx +4 -26
- package/src/components/fields/NumberField.tsx +9 -27
- package/src/components/fields/PortableTextField.tsx +61 -49
- package/src/components/fields/RelationshipBlockField.tsx +233 -0
- package/src/components/fields/RelationshipField.tsx +59 -13
- package/src/components/fields/SelectField.tsx +6 -4
- package/src/components/fields/TextField.tsx +9 -24
- package/src/components/fields/UploadField.tsx +613 -0
- package/src/components/fields/VideoField.tsx +73 -0
- package/src/components/fields/extensions/blockComponents.tsx +11 -1
- package/src/components/fields/extensions/blocksStore.ts +1 -1
- package/src/components/fields/index.ts +12 -1
- package/src/components/layout/Layout.tsx +1 -1
- package/src/lib/api.ts +163 -0
- package/src/lib/config.ts +1 -1
- package/src/lib/dataStore.ts +87 -30
- package/src/lib/date-utils.ts +69 -0
- package/src/lib/db/version-adapter.ts +248 -0
- package/src/lib/i18n.tsx +353 -0
- package/src/lib/slugify.ts +15 -0
- package/src/lib/validation.ts +250 -0
- package/src/pages/api/[collection]/[id]/publish.ts +12 -4
- package/src/pages/api/[collection]/[id]/versions.ts +39 -9
- package/src/pages/api/[collection]/[id].ts +13 -1
- package/src/pages/api/[collection]/index.ts +5 -6
- package/src/styles/main.css +12 -2
- package/src/components/blocks/BlockEditModal.MARKER +0 -12
- package/src/components/fields/FileField.tsx +0 -390
- package/src/components/fields/HybridContentField.tsx +0 -109
- 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;
|