@kyro-cms/admin 0.1.5 → 0.1.7

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