@kyro-cms/admin 0.1.6 → 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 (179) hide show
  1. package/README.md +149 -51
  2. package/package.json +54 -5
  3. package/src/collections/auth/index.ts +2 -2
  4. package/src/collections/portfolio/index.ts +343 -0
  5. package/src/components/ActionBar.tsx +153 -16
  6. package/src/components/Admin.tsx +137 -28
  7. package/src/components/ApiExplorer.tsx +325 -0
  8. package/src/components/ApiKeysManager.tsx +563 -0
  9. package/src/components/AuditLogsPage.tsx +664 -0
  10. package/src/components/AutoForm.tsx +2155 -770
  11. package/src/components/BrandingHub.tsx +267 -0
  12. package/src/components/BulkActionsBar.tsx +3 -3
  13. package/src/components/CreateView.tsx +4 -4
  14. package/src/components/Dashboard.tsx +393 -0
  15. package/src/components/DetailView.tsx +200 -58
  16. package/src/components/DeveloperCenter.tsx +403 -0
  17. package/src/components/EnhancedListView.tsx +890 -0
  18. package/src/components/GraphQLExplorer.tsx +675 -0
  19. package/src/components/GraphQLPlayground.tsx +627 -0
  20. package/src/components/ListView.tsx +192 -54
  21. package/src/components/MediaGallery.tsx +1569 -0
  22. package/src/components/Modal.tsx +206 -0
  23. package/src/components/RestPlayground.tsx +951 -0
  24. package/src/components/Sidebar.astro +237 -0
  25. package/src/components/ThemeProvider.tsx +8 -2
  26. package/src/components/UserManagement.tsx +204 -0
  27. package/src/components/VersionHistoryPanel.tsx +3 -3
  28. package/src/components/WebhookManager.tsx +608 -0
  29. package/src/components/blocks/AccordionBlock.tsx +65 -0
  30. package/src/components/blocks/ArrayBlock.tsx +84 -0
  31. package/src/components/blocks/BlockEditModal.tsx +363 -0
  32. package/src/components/blocks/ButtonBlock.tsx +64 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +551 -0
  34. package/src/components/blocks/CodeBlock.tsx +114 -0
  35. package/src/components/blocks/ColumnsBlock.tsx +93 -0
  36. package/src/components/blocks/DividerBlock.tsx +43 -0
  37. package/src/components/blocks/FileBlock.tsx +63 -0
  38. package/src/components/blocks/HeadingBlock.tsx +59 -0
  39. package/src/components/blocks/HeroBlock.tsx +99 -0
  40. package/src/components/blocks/ImageBlock.tsx +82 -0
  41. package/src/components/blocks/LinkBlock.tsx +65 -0
  42. package/src/components/blocks/ListBlock.tsx +60 -0
  43. package/src/components/blocks/ParagraphBlock.tsx +61 -0
  44. package/src/components/blocks/RelationshipBlock.tsx +72 -0
  45. package/src/components/blocks/RichTextBlock.tsx +66 -0
  46. package/src/components/blocks/VStackBlock.tsx +61 -0
  47. package/src/components/blocks/VideoBlock.tsx +65 -0
  48. package/src/components/blocks/index.ts +10 -0
  49. package/src/components/fields/AccordionField.tsx +213 -0
  50. package/src/components/fields/ArrayField.tsx +241 -0
  51. package/src/components/fields/BlocksField.tsx +323 -0
  52. package/src/components/fields/ButtonField.tsx +53 -0
  53. package/src/components/fields/CheckboxField.tsx +18 -8
  54. package/src/components/fields/ChildrenField.tsx +48 -0
  55. package/src/components/fields/CodeField.tsx +294 -0
  56. package/src/components/fields/ColumnsField.tsx +137 -0
  57. package/src/components/fields/DateField.tsx +24 -12
  58. package/src/components/fields/EditorClient.tsx +537 -0
  59. package/src/components/fields/HeadingField.tsx +31 -0
  60. package/src/components/fields/HeroField.tsx +101 -0
  61. package/src/components/fields/JSONField.tsx +341 -0
  62. package/src/components/fields/LinkField.tsx +81 -0
  63. package/src/components/fields/ListField.tsx +74 -0
  64. package/src/components/fields/MarkdownField.tsx +260 -0
  65. package/src/components/fields/NumberField.tsx +25 -13
  66. package/src/components/fields/PortableTextField.tsx +155 -0
  67. package/src/components/fields/PortableTextRenderer.tsx +68 -0
  68. package/src/components/fields/RelationshipBlockField.tsx +233 -0
  69. package/src/components/fields/RelationshipField.tsx +278 -60
  70. package/src/components/fields/SelectField.tsx +28 -16
  71. package/src/components/fields/TextField.tsx +31 -15
  72. package/src/components/fields/UploadField.tsx +613 -0
  73. package/src/components/fields/VideoField.tsx +73 -0
  74. package/src/components/fields/extensions/blockComponents.tsx +247 -0
  75. package/src/components/fields/extensions/blocksStore.ts +273 -0
  76. package/src/components/fields/index.ts +24 -0
  77. package/src/components/index.ts +1 -2
  78. package/src/components/layout/Header.tsx +2 -2
  79. package/src/components/layout/Layout.tsx +3 -3
  80. package/src/components/ui/Badge.tsx +9 -4
  81. package/src/components/ui/BlockDrawer.tsx +79 -0
  82. package/src/components/ui/Button.tsx +1 -1
  83. package/src/components/ui/CommandPalette.tsx +362 -0
  84. package/src/components/ui/CommandPaletteWrapper.tsx +97 -0
  85. package/src/components/ui/Dropdown.tsx +1 -1
  86. package/src/components/ui/Modal.tsx +37 -12
  87. package/src/components/ui/PromptModal.tsx +94 -0
  88. package/src/components/ui/SlidePanel.tsx +43 -16
  89. package/src/components/ui/Toast.tsx +80 -14
  90. package/src/env.d.ts +16 -0
  91. package/src/env.ts +20 -0
  92. package/src/index.ts +0 -1
  93. package/src/layouts/AdminLayout.astro +164 -170
  94. package/src/layouts/AuthLayout.astro +23 -6
  95. package/src/lib/MediaService.ts +541 -0
  96. package/src/lib/api.ts +163 -0
  97. package/src/lib/auth/sqlite-adapter.ts +319 -0
  98. package/src/lib/config.ts +23 -7
  99. package/src/lib/dataStore.ts +188 -73
  100. package/src/lib/date-utils.ts +69 -0
  101. package/src/lib/db/adapter.ts +54 -0
  102. package/src/lib/db/drizzle-mysql-adapter.ts +194 -0
  103. package/src/lib/db/drizzle-mysql-auth-adapter.ts +327 -0
  104. package/src/lib/db/drizzle-postgres-adapter.ts +202 -0
  105. package/src/lib/db/drizzle-postgres-auth-adapter.ts +304 -0
  106. package/src/lib/db/drizzle-sqlite-adapter.ts +227 -0
  107. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +548 -0
  108. package/src/lib/db/index.ts +449 -0
  109. package/src/lib/db/mongodb-adapter.ts +207 -0
  110. package/src/lib/db/mongodb-auth-adapter.ts +305 -0
  111. package/src/lib/db/schema/mysql-auth.ts +113 -0
  112. package/src/lib/db/schema/mysql-content.ts +20 -0
  113. package/src/lib/db/schema/postgres-auth.ts +116 -0
  114. package/src/lib/db/schema/postgres-content.ts +35 -0
  115. package/src/lib/db/schema/postgres-media.ts +52 -0
  116. package/src/lib/db/schema/postgres-settings.ts +11 -0
  117. package/src/lib/db/schema/sqlite-auth.ts +112 -0
  118. package/src/lib/db/schema/sqlite-content.ts +20 -0
  119. package/src/lib/db/version-adapter.ts +248 -0
  120. package/src/lib/graphql/index.ts +1 -0
  121. package/src/lib/graphql/schema.ts +443 -0
  122. package/src/lib/i18n.tsx +353 -0
  123. package/src/lib/rate-limit.ts +267 -0
  124. package/src/lib/slugify.ts +15 -0
  125. package/src/lib/storage.ts +374 -0
  126. package/src/lib/store.ts +85 -0
  127. package/src/lib/validation.ts +250 -0
  128. package/src/middleware.ts +70 -11
  129. package/src/pages/[collection]/[id].astro +178 -122
  130. package/src/pages/[collection]/index.astro +24 -156
  131. package/src/pages/admin/api-explorer.astro +98 -0
  132. package/src/pages/admin/graphql-explorer.astro +40 -0
  133. package/src/pages/admin/graphql.astro +97 -0
  134. package/src/pages/admin/index.astro +200 -139
  135. package/src/pages/admin/keys.astro +8 -0
  136. package/src/pages/admin/rest-playground.astro +44 -0
  137. package/src/pages/admin/webhooks.astro +8 -0
  138. package/src/pages/api/[collection]/[id]/publish.ts +52 -0
  139. package/src/pages/api/[collection]/[id]/unpublish.ts +42 -0
  140. package/src/pages/api/[collection]/[id]/versions.ts +66 -0
  141. package/src/pages/api/[collection]/[id].ts +114 -159
  142. package/src/pages/api/[collection]/index.ts +150 -230
  143. package/src/pages/api/auth/[id].ts +48 -69
  144. package/src/pages/api/auth/audit-logs.ts +20 -43
  145. package/src/pages/api/auth/login.ts +159 -45
  146. package/src/pages/api/auth/logout.ts +42 -24
  147. package/src/pages/api/auth/refresh.ts +119 -0
  148. package/src/pages/api/auth/register.ts +110 -40
  149. package/src/pages/api/auth/users.ts +22 -97
  150. package/src/pages/api/collections.ts +59 -0
  151. package/src/pages/api/globals/[slug]/test.ts +172 -0
  152. package/src/pages/api/globals/[slug].ts +42 -0
  153. package/src/pages/api/graphql.ts +90 -0
  154. package/src/pages/api/health.ts +417 -40
  155. package/src/pages/api/keys/[id].ts +26 -0
  156. package/src/pages/api/keys/index.ts +75 -0
  157. package/src/pages/api/media/[id].ts +309 -0
  158. package/src/pages/api/media/folders.ts +609 -0
  159. package/src/pages/api/media/index.ts +146 -0
  160. package/src/pages/api/media/resize.ts +267 -0
  161. package/src/pages/api/search.ts +82 -0
  162. package/src/pages/api/slug-availability.ts +70 -0
  163. package/src/pages/api/storage-config.ts +20 -0
  164. package/src/pages/api/storage-status.ts +206 -0
  165. package/src/pages/api/upload.ts +334 -0
  166. package/src/pages/api/webhooks/index.ts +71 -0
  167. package/src/pages/audit/index.astro +2 -104
  168. package/src/pages/login.astro +11 -11
  169. package/src/pages/media.astro +10 -0
  170. package/src/pages/preview/[collection]/[id].astro +178 -0
  171. package/src/pages/register.astro +13 -13
  172. package/src/pages/roles/index.astro +21 -21
  173. package/src/pages/settings/[slug].astro +162 -0
  174. package/src/pages/settings/index.astro +9 -0
  175. package/src/pages/users/[id].astro +29 -21
  176. package/src/pages/users/index.astro +22 -17
  177. package/src/pages/users/new.astro +18 -17
  178. package/src/styles/main.css +563 -128
  179. package/src/components/layout/Sidebar.tsx +0 -497
@@ -1,4 +1,4 @@
1
- import type { TextField as TextFieldType } from '@kyro-cms/core';
1
+ import type { TextField as TextFieldType } from "@kyro-cms/core/client";
2
2
 
3
3
  interface TextFieldComponentProps {
4
4
  field: TextFieldType;
@@ -8,18 +8,30 @@ interface TextFieldComponentProps {
8
8
  disabled?: boolean;
9
9
  }
10
10
 
11
- export default function TextField({ field, value = '', onChange, error, disabled }: TextFieldComponentProps) {
12
- const inputType = field.variant === 'email' ? 'email'
13
- : field.variant === 'password' ? 'password'
14
- : field.variant === 'url' ? 'url'
15
- : 'text';
11
+ export default function TextField({
12
+ field,
13
+ value = "",
14
+ onChange,
15
+ error,
16
+ disabled,
17
+ }: TextFieldComponentProps) {
18
+ const inputType =
19
+ field.variant === "email"
20
+ ? "email"
21
+ : field.variant === "password"
22
+ ? "password"
23
+ : field.variant === "url"
24
+ ? "url"
25
+ : "text";
16
26
 
17
27
  return (
18
28
  <div className="space-y-1">
19
29
  {field.label && (
20
- <label className="block text-sm font-medium text-gray-700">
30
+ <label className="block text-sm font-medium text-[var(--kyro-text-primary)]">
21
31
  {field.label}
22
- {field.required && <span className="text-red-500 ml-1">*</span>}
32
+ {field.required && (
33
+ <span className="text-[var(--kyro-error)] ml-1">*</span>
34
+ )}
23
35
  </label>
24
36
  )}
25
37
  <input
@@ -34,16 +46,20 @@ export default function TextField({ field, value = '', onChange, error, disabled
34
46
  required={field.required}
35
47
  className={`w-full px-3 py-2 border rounded-md text-sm transition-colors ${
36
48
  error
37
- ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
38
- : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
39
- } ${disabled || field.admin?.readOnly ? 'bg-gray-50 text-gray-500' : 'bg-white'}`}
49
+ ? "border-[var(--kyro-error)] focus:border-[var(--kyro-error)] focus:ring-[var(--kyro-error)]"
50
+ : "border-[var(--kyro-border)] focus:border-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
51
+ } ${
52
+ disabled || field.admin?.readOnly
53
+ ? "bg-[var(--kyro-bg-secondary)] text-[var(--kyro-text-secondary)] opacity-50"
54
+ : "bg-[var(--kyro-surface)] text-[var(--kyro-text-primary)]"
55
+ }`}
40
56
  />
41
57
  {field.admin?.description && !error && (
42
- <p className="text-xs text-gray-500">{field.admin.description}</p>
43
- )}
44
- {error && (
45
- <p className="text-xs text-red-600">{error}</p>
58
+ <p className="text-xs text-[var(--kyro-text-muted)]">
59
+ {field.admin.description}
60
+ </p>
46
61
  )}
62
+ {error && <p className="text-xs text-[var(--kyro-error)]">{error}</p>}
47
63
  </div>
48
64
  );
49
65
  }
@@ -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
+ }