@kyro-cms/admin 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +5 -3
  2. package/src/components/Admin.tsx +1 -1
  3. package/src/components/AutoForm.tsx +966 -337
  4. package/src/components/CreateView.tsx +1 -1
  5. package/src/components/DetailView.tsx +1 -1
  6. package/src/components/EnhancedListView.tsx +156 -52
  7. package/src/components/ListView.tsx +1 -1
  8. package/src/components/Modal.tsx +65 -8
  9. package/src/components/Sidebar.astro +2 -2
  10. package/src/components/ThemeProvider.tsx +8 -2
  11. package/src/components/blocks/AccordionBlock.tsx +20 -52
  12. package/src/components/blocks/ArrayBlock.tsx +40 -31
  13. package/src/components/blocks/BlockEditModal.tsx +170 -581
  14. package/src/components/blocks/ButtonBlock.tsx +27 -128
  15. package/src/components/blocks/CodeBlock.tsx +88 -40
  16. package/src/components/blocks/ColumnsBlock.tsx +27 -85
  17. package/src/components/blocks/FileBlock.tsx +38 -39
  18. package/src/components/blocks/HeadingBlock.tsx +9 -31
  19. package/src/components/blocks/HeroBlock.tsx +42 -100
  20. package/src/components/blocks/ImageBlock.tsx +6 -7
  21. package/src/components/blocks/LinkBlock.tsx +27 -33
  22. package/src/components/blocks/ListBlock.tsx +47 -26
  23. package/src/components/blocks/RelationshipBlock.tsx +26 -233
  24. package/src/components/blocks/RichTextBlock.tsx +66 -0
  25. package/src/components/blocks/VStackBlock.tsx +23 -37
  26. package/src/components/blocks/VideoBlock.tsx +52 -32
  27. package/src/components/fields/AccordionField.tsx +213 -0
  28. package/src/components/fields/ArrayField.tsx +241 -0
  29. package/src/components/fields/BlocksField.tsx +5 -5
  30. package/src/components/fields/ButtonField.tsx +53 -0
  31. package/src/components/fields/CheckboxField.tsx +7 -3
  32. package/src/components/fields/ChildrenField.tsx +48 -0
  33. package/src/components/fields/CodeField.tsx +154 -94
  34. package/src/components/fields/ColumnsField.tsx +137 -0
  35. package/src/components/fields/DateField.tsx +9 -24
  36. package/src/components/fields/EditorClient.tsx +426 -160
  37. package/src/components/fields/HeadingField.tsx +31 -0
  38. package/src/components/fields/HeroField.tsx +101 -0
  39. package/src/components/fields/JSONField.tsx +7 -27
  40. package/src/components/fields/LinkField.tsx +81 -0
  41. package/src/components/fields/ListField.tsx +74 -0
  42. package/src/components/fields/MarkdownField.tsx +4 -26
  43. package/src/components/fields/NumberField.tsx +9 -27
  44. package/src/components/fields/PortableTextField.tsx +61 -49
  45. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  46. package/src/components/fields/RelationshipField.tsx +59 -13
  47. package/src/components/fields/SelectField.tsx +6 -4
  48. package/src/components/fields/TextField.tsx +9 -24
  49. package/src/components/fields/UploadField.tsx +613 -0
  50. package/src/components/fields/VideoField.tsx +73 -0
  51. package/src/components/fields/extensions/blockComponents.tsx +11 -1
  52. package/src/components/fields/extensions/blocksStore.ts +1 -1
  53. package/src/components/fields/index.ts +12 -1
  54. package/src/components/layout/Layout.tsx +1 -1
  55. package/src/lib/api.ts +163 -0
  56. package/src/lib/config.ts +1 -1
  57. package/src/lib/dataStore.ts +87 -30
  58. package/src/lib/date-utils.ts +69 -0
  59. package/src/lib/db/version-adapter.ts +248 -0
  60. package/src/lib/i18n.tsx +353 -0
  61. package/src/lib/slugify.ts +15 -0
  62. package/src/lib/validation.ts +250 -0
  63. package/src/pages/api/[collection]/[id]/publish.ts +12 -4
  64. package/src/pages/api/[collection]/[id]/versions.ts +39 -9
  65. package/src/pages/api/[collection]/[id].ts +13 -1
  66. package/src/pages/api/[collection]/index.ts +5 -6
  67. package/src/styles/main.css +12 -2
  68. package/src/components/blocks/BlockEditModal.MARKER +0 -12
  69. package/src/components/fields/FileField.tsx +0 -390
  70. package/src/components/fields/HybridContentField.tsx +0 -109
  71. package/src/components/fields/ImageField.tsx +0 -429
@@ -1,390 +0,0 @@
1
- import React, { useState, useEffect, useRef } from "react";
2
-
3
- interface FileFieldProps {
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
- mimeType: string;
15
- size?: number;
16
- title?: string;
17
- folder?: string;
18
- }
19
-
20
- interface MediaFolder {
21
- name: string;
22
- path: string;
23
- }
24
-
25
- export function FileField({
26
- field,
27
- value,
28
- onChange,
29
- disabled,
30
- }: FileFieldProps) {
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 [showUrlInput, setShowUrlInput] = useState(false);
40
- const [urlValue, setUrlValue] = useState("");
41
- const [urlError, setUrlError] = useState("");
42
-
43
- const fieldLabel = field?.label || field?.name || "File";
44
- const maxCount = field.maxCount || 1;
45
- const isMultiple = maxCount > 1;
46
- const currentValue = Array.isArray(value) ? value : value ? [value] : [];
47
- const canAddMore = currentValue.length < maxCount;
48
-
49
- useEffect(() => {
50
- if (showPicker) {
51
- loadFolders();
52
- loadMedia();
53
- }
54
- }, [showPicker, selectedFolder]);
55
-
56
- const loadFolders = async () => {
57
- try {
58
- const resp = await fetch("/api/media/folders?t=" + Date.now(), {
59
- credentials: "include",
60
- });
61
- const result = await resp.json();
62
- setFolders(result.folders || []);
63
- } catch {
64
- setFolders([]);
65
- }
66
- };
67
-
68
- const loadMedia = async () => {
69
- setMediaLoading(true);
70
- try {
71
- let url = `/api/media?limit=60&sortBy=createdAt&sortDir=desc&t=${Date.now()}&mimeType=application,video,audio`;
72
- if (selectedFolder) {
73
- url += "&folder=" + encodeURIComponent(selectedFolder);
74
- }
75
- const resp = await fetch(url, { credentials: "include" });
76
- const result = await resp.json();
77
- setMediaItems(result.docs || []);
78
- } catch {
79
- setMediaItems([]);
80
- } finally {
81
- setMediaLoading(false);
82
- }
83
- };
84
-
85
- const uploadFile = async (file: File) => {
86
- setUploading(true);
87
- try {
88
- const formData = new FormData();
89
- formData.append("file", file);
90
- if (selectedFolder) {
91
- formData.append("folder", selectedFolder);
92
- }
93
- const resp = await fetch("/api/upload", {
94
- method: "POST",
95
- body: formData,
96
- credentials: "include",
97
- });
98
- if (!resp.ok) throw new Error("Upload failed");
99
- const result = await resp.json();
100
- const newItem = {
101
- id: result.id,
102
- filename: result.filename,
103
- url: result.url,
104
- mimeType: result.mimeType,
105
- size: result.size,
106
- };
107
- if (isMultiple) {
108
- onChange([...currentValue, newItem]);
109
- } else {
110
- onChange(newItem);
111
- }
112
- } catch (err) {
113
- console.error("Upload failed:", err);
114
- } finally {
115
- setUploading(false);
116
- }
117
- };
118
-
119
- const handleDrop = (e: React.DragEvent) => {
120
- e.preventDefault();
121
- const files = Array.from(e.dataTransfer.files);
122
- if (files.length > 0) {
123
- uploadFile(files[0]);
124
- }
125
- };
126
-
127
- const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
128
- const files = e.target.files;
129
- if (files && files.length > 0) {
130
- uploadFile(files[0]);
131
- }
132
- };
133
-
134
- const addByUrl = async () => {
135
- if (!urlValue.trim()) return;
136
- setUrlError("");
137
- try {
138
- new URL(urlValue);
139
- } catch {
140
- setUrlError("Invalid URL");
141
- return;
142
- }
143
- const newItem = {
144
- id: `url-${Date.now()}`,
145
- filename: urlValue.split("/").pop() || "file",
146
- url: urlValue,
147
- mimeType: "application/octet-stream",
148
- };
149
- if (isMultiple) {
150
- onChange([...currentValue, newItem]);
151
- } else {
152
- onChange(newItem);
153
- }
154
- setUrlValue("");
155
- setShowUrlInput(false);
156
- };
157
-
158
- const removeItem = (index: number) => {
159
- const newItems = [...currentValue];
160
- newItems.splice(index, 1);
161
- onChange(newItems);
162
- };
163
-
164
- const formatFileSize = (bytes?: number) => {
165
- if (!bytes) return "";
166
- const kb = bytes / 1024;
167
- if (kb < 1024) return `${kb.toFixed(1)} KB`;
168
- const mb = kb / 1024;
169
- return `${mb.toFixed(1)} MB`;
170
- };
171
-
172
- const getFileIcon = (mimeType?: string) => {
173
- if (!mimeType) return "📄";
174
- if (mimeType.startsWith("video/")) return "🎬";
175
- if (mimeType.startsWith("audio/")) return "🎵";
176
- if (mimeType.includes("pdf")) return "📕";
177
- if (mimeType.includes("word") || mimeType.includes("document")) return "📝";
178
- if (mimeType.includes("spreadsheet") || mimeType.includes("excel"))
179
- return "📊";
180
- if (mimeType.includes("presentation") || mimeType.includes("powerpoint"))
181
- return "📽️";
182
- if (mimeType.includes("zip") || mimeType.includes("archive")) return "📦";
183
- return "📄";
184
- };
185
-
186
- const renderPreview = (item: any, index: number) => (
187
- <div
188
- key={index}
189
- className="flex items-center gap-3 p-3 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-surface)]"
190
- >
191
- <div className="text-xl">{getFileIcon(item.mimeType)}</div>
192
- <div className="flex-1 min-w-0">
193
- <div className="text-sm font-medium text-[var(--kyro-text-primary)] truncate">
194
- {item.filename || item.title || "Untitled"}
195
- </div>
196
- {item.size && (
197
- <div className="text-xs text-[var(--kyro-text-muted)]">
198
- {formatFileSize(item.size)}
199
- </div>
200
- )}
201
- </div>
202
- <button
203
- type="button"
204
- onClick={() => removeItem(index)}
205
- className="text-[var(--kyro-text-muted)] hover:text-red-500 p-1"
206
- >
207
- <svg
208
- className="w-4 h-4"
209
- fill="none"
210
- stroke="currentColor"
211
- viewBox="0 0 24 24"
212
- >
213
- <path
214
- strokeLinecap="round"
215
- strokeLinejoin="round"
216
- strokeWidth="2"
217
- d="M6 18L18 6M6 6l12 12"
218
- />
219
- </svg>
220
- </button>
221
- </div>
222
- );
223
-
224
- return (
225
- <div className="space-y-3">
226
- {currentValue.length > 0 && (
227
- <div className="space-y-2">
228
- {currentValue.map((item, index) => renderPreview(item, index))}
229
- </div>
230
- )}
231
-
232
- {canAddMore && (
233
- <>
234
- <div className="flex gap-2 flex-wrap">
235
- <button
236
- type="button"
237
- onClick={() => inputRef.current?.click()}
238
- disabled={disabled || uploading}
239
- 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-sidebar-active)] transition-colors disabled:opacity-50"
240
- >
241
- {uploading ? "Uploading..." : `+ Upload ${fieldLabel}`}
242
- </button>
243
- <input
244
- ref={inputRef}
245
- type="file"
246
- onChange={handleFileSelect}
247
- className="hidden"
248
- accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.mp4,.mov,.avi,.mp3,.wav"
249
- />
250
- <button
251
- type="button"
252
- onClick={() => setShowPicker(true)}
253
- disabled={disabled}
254
- 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-sidebar-active)] transition-colors"
255
- >
256
- Library
257
- </button>
258
- <button
259
- type="button"
260
- onClick={() => setShowUrlInput(!showUrlInput)}
261
- disabled={disabled}
262
- 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-sidebar-active)] transition-colors"
263
- >
264
- URL
265
- </button>
266
- </div>
267
-
268
- {showUrlInput && (
269
- <div className="flex gap-2 items-center">
270
- <input
271
- ref={urlInputRef}
272
- type="url"
273
- placeholder="https://example.com/file.pdf"
274
- value={urlValue}
275
- onChange={(e) => {
276
- setUrlValue(e.target.value);
277
- setUrlError("");
278
- }}
279
- onKeyDown={(e) => e.key === "Enter" && addByUrl()}
280
- className="flex-1 px-3 py-2 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)]"
281
- />
282
- <button
283
- type="button"
284
- onClick={addByUrl}
285
- className="px-3 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded text-sm font-medium"
286
- >
287
- Add
288
- </button>
289
- {urlError && (
290
- <span className="text-xs text-red-500">{urlError}</span>
291
- )}
292
- </div>
293
- )}
294
-
295
- {showPicker && (
296
- <div className="border border-[var(--kyro-border)] rounded-lg p-4 space-y-3 bg-[var(--kyro-surface)]">
297
- <div className="flex items-center justify-between">
298
- <h3 className="text-sm font-medium">Select File</h3>
299
- <button
300
- type="button"
301
- onClick={() => setShowPicker(false)}
302
- className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
303
- >
304
- Close
305
- </button>
306
- </div>
307
-
308
- {folders.length > 0 && (
309
- <div className="flex gap-1 flex-wrap">
310
- <button
311
- type="button"
312
- onClick={() => setSelectedFolder("")}
313
- className={`px-2 py-1 text-xs rounded ${
314
- !selectedFolder
315
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
316
- : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
317
- }`}
318
- >
319
- All
320
- </button>
321
- {folders.map((folder) => (
322
- <button
323
- key={folder.path}
324
- type="button"
325
- onClick={() => setSelectedFolder(folder.path)}
326
- className={`px-2 py-1 text-xs rounded ${
327
- selectedFolder === folder.path
328
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
329
- : "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
330
- }`}
331
- >
332
- {folder.name}
333
- </button>
334
- ))}
335
- </div>
336
- )}
337
-
338
- {mediaLoading ? (
339
- <div className="text-center py-4 text-[var(--kyro-text-muted)]">
340
- Loading...
341
- </div>
342
- ) : (
343
- <div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto">
344
- {mediaItems.map((item) => {
345
- const isSelected = currentValue.some(
346
- (v) => v.url === item.url,
347
- );
348
- return (
349
- <button
350
- key={item.id}
351
- type="button"
352
- onClick={() => {
353
- if (isMultiple) {
354
- onChange([...currentValue, item]);
355
- } else {
356
- onChange(item);
357
- }
358
- setShowPicker(false);
359
- }}
360
- disabled={isSelected}
361
- className={`p-2 rounded border text-left hover:border-[var(--kyro-sidebar-active)] transition-colors ${
362
- isSelected
363
- ? "border-[var(--kyro-sidebar-active)] bg-[var(--kyro-sidebar-active)]/10"
364
- : "border-[var(--kyro-border)]"
365
- }`}
366
- >
367
- <div className="text-lg mb-1 text-center">
368
- {getFileIcon(item.mimeType)}
369
- </div>
370
- <div className="text-[10px] text-[var(--kyro-text-muted)] truncate">
371
- {item.filename}
372
- </div>
373
- </button>
374
- );
375
- })}
376
- </div>
377
- )}
378
-
379
- {mediaItems.length === 0 && !mediaLoading && (
380
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm">
381
- No files found
382
- </div>
383
- )}
384
- </div>
385
- )}
386
- </>
387
- )}
388
- </div>
389
- );
390
- }
@@ -1,109 +0,0 @@
1
- import React, { useState } from "react";
2
- import PortableTextField from "./PortableTextField";
3
- import { BlocksField } from "./BlocksField";
4
-
5
- interface HybridContentFieldProps {
6
- field: any;
7
- value?: any;
8
- onChange?: (value: any) => void;
9
- error?: string;
10
- disabled?: boolean;
11
- }
12
-
13
- export const HybridContentField: React.FC<HybridContentFieldProps> = ({
14
- field,
15
- value,
16
- onChange,
17
- error,
18
- disabled,
19
- }) => {
20
- const [mode, setMode] = useState<"richtext" | "blocks">(() => {
21
- if (typeof value === "string" && value.trim().startsWith("<")) {
22
- return "richtext";
23
- }
24
- if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
25
- return "blocks";
26
- }
27
- return "richtext";
28
- });
29
-
30
- const handleModeChange = (newMode: "richtext" | "blocks") => {
31
- if (newMode === mode) return;
32
-
33
- if (newMode === "blocks" && mode === "richtext") {
34
- if (value && typeof value === "string" && value.trim()) {
35
- onChange?.([]);
36
- } else {
37
- onChange?.([]);
38
- }
39
- } else if (newMode === "richtext" && mode === "blocks") {
40
- if (Array.isArray(value) && value.length > 0) {
41
- if (
42
- !confirm("Switching to Rich Text will convert your blocks. Continue?")
43
- ) {
44
- return;
45
- }
46
- }
47
- onChange?.("");
48
- }
49
- setMode(newMode);
50
- };
51
-
52
- return (
53
- <div className="kyro-form-field">
54
- <div className="flex items-center justify-between mb-2">
55
- <label className="kyro-form-label">
56
- {field.label || field.name}
57
- {field.required && (
58
- <span className="kyro-form-label-required">*</span>
59
- )}
60
- </label>
61
-
62
- <div className="flex items-center gap-1 bg-[var(--kyro-surface-accent)] p-0.5 rounded-lg">
63
- <button
64
- type="button"
65
- onClick={() => handleModeChange("richtext")}
66
- className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${mode === "richtext"
67
- ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]"
68
- : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
69
- }`}
70
- >
71
- Rich Text Editor
72
- </button>
73
- <button
74
- type="button"
75
- onClick={() => handleModeChange("blocks")}
76
- className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${mode === "blocks"
77
- ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]"
78
- : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)]"
79
- }`}
80
- >
81
- Block Editor
82
- </button>
83
- </div>
84
- </div>
85
-
86
- {mode === "richtext" ? (
87
- <PortableTextField
88
- field={field}
89
- value={typeof value === "string" ? value : ""}
90
- onChange={(newValue) => onChange?.(newValue)}
91
- error={error}
92
- disabled={disabled}
93
- />
94
- ) : (
95
- <BlocksField
96
- field={field}
97
- value={Array.isArray(value) ? value : []}
98
- onChange={(newValue) => onChange?.(newValue)}
99
- error={error}
100
- disabled={disabled}
101
- />
102
- )}
103
-
104
- {field.admin?.description && !error && (
105
- <p className="kyro-form-help mt-2">{field.admin.description}</p>
106
- )}
107
- </div>
108
- );
109
- };