@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,1569 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Spinner } from "./ui/Spinner";
4
+ import { ConfirmModal } from "./ui/Modal";
5
+ import { SlidePanel } from "./ui/SlidePanel";
6
+ import { Badge } from "./ui/Badge";
7
+ import {
8
+ Trash2,
9
+ Download,
10
+ Maximize2,
11
+ X,
12
+ FileIcon,
13
+ FolderInput,
14
+ FolderPlus,
15
+ Grid,
16
+ Link,
17
+ Crop as CropIcon,
18
+ Film,
19
+ Music,
20
+ FileText,
21
+ Archive,
22
+ } from "lucide-react";
23
+ import { PromptModal } from "./ui/PromptModal";
24
+ import ReactCrop, {
25
+ type Crop,
26
+ centerCrop,
27
+ makeAspectCrop,
28
+ convertToPixelCrop,
29
+ } from "react-image-crop";
30
+ import "react-image-crop/dist/ReactCrop.css";
31
+
32
+ interface MediaItem {
33
+ id: string;
34
+ title: string;
35
+ filename: string;
36
+ originalName?: string;
37
+ url: string;
38
+ thumbnailUrl?: string;
39
+ type: "image" | "video" | "audio" | "document" | "archive" | "other";
40
+ mimeType: string;
41
+ fileSize: number;
42
+ folder?: string;
43
+ alt?: string;
44
+ caption?: string;
45
+ createdAt: string;
46
+ updatedAt?: string;
47
+ }
48
+
49
+ function getAbsoluteUrl(relativeUrl: string): string {
50
+ if (typeof window === "undefined") return relativeUrl;
51
+ // Remote URLs and blob URLs are returned as-is
52
+ if (relativeUrl.startsWith("http") || relativeUrl.startsWith("blob:")) {
53
+ return relativeUrl;
54
+ }
55
+ // Remove consecutive slashes for local paths (e.g. //photo... -> /photo...)
56
+ const sanitized = relativeUrl.replace(/^\/+/, "/");
57
+ return `${window.location.origin}${sanitized}`;
58
+ }
59
+
60
+ type FilterType =
61
+ | "all"
62
+ | "image"
63
+ | "video"
64
+ | "audio"
65
+ | "document"
66
+ | "archive"
67
+ | "other";
68
+
69
+ function formatFileSize(bytes: number): string {
70
+ if (bytes === 0) return "0 B";
71
+ const k = 1024;
72
+ const sizes = ["B", "KB", "MB", "GB"];
73
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
74
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
75
+ }
76
+
77
+ function formatDate(dateString: string): string {
78
+ const date = new Date(dateString);
79
+ return date.toLocaleDateString("en-US", {
80
+ month: "short",
81
+ day: "numeric",
82
+ year: "numeric",
83
+ });
84
+ }
85
+
86
+ function getFileType(mimeType: string): FilterType {
87
+ if (mimeType.startsWith("image/")) return "image";
88
+ if (mimeType.startsWith("video/")) return "video";
89
+ if (mimeType.startsWith("audio/")) return "audio";
90
+ if (
91
+ mimeType.includes("zip") ||
92
+ mimeType.includes("tar") ||
93
+ mimeType.includes("rar")
94
+ )
95
+ return "archive";
96
+ if (
97
+ mimeType.includes("pdf") ||
98
+ mimeType.includes("document") ||
99
+ mimeType.includes("text")
100
+ )
101
+ return "document";
102
+ return "other";
103
+ }
104
+
105
+ function getFileExtension(filename: string | undefined): string {
106
+ if (!filename) return "FILE";
107
+ const parts = filename.split(".");
108
+ if (parts.length > 1) {
109
+ return parts.pop()!.toUpperCase();
110
+ }
111
+ return "FILE";
112
+ }
113
+
114
+ export function MediaGallery() {
115
+ const [items, setItems] = useState<MediaItem[]>([]);
116
+ const [loading, setLoading] = useState(true);
117
+ const [uploading, setUploading] = useState(false);
118
+ const [uploadProgress, setUploadProgress] = useState<number>(0);
119
+ const [filter, setFilter] = useState<FilterType>("all");
120
+ const [searchQuery, setSearchQuery] = useState("");
121
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
122
+ const [uploadQueue, setUploadQueue] = useState<
123
+ Array<{
124
+ name: string;
125
+ idx: number;
126
+ progress: number;
127
+ controller?: AbortController;
128
+ }>
129
+ >([]);
130
+ const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
131
+ const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
132
+ const [uploadDragOver, setUploadDragOver] = useState(false);
133
+ const fileInputRef = useRef<HTMLInputElement>(null);
134
+ const [panelItem, setPanelItem] = useState<MediaItem | null>(null);
135
+ const [panelName, setPanelName] = useState<string>("");
136
+ const [panelAlt, setPanelAlt] = useState<string>("");
137
+ const [currentFolder, setCurrentFolder] = useState<string>("");
138
+ const [availableFolders, setAvailableFolders] = useState<string[]>([]);
139
+ const [uploadFolder, setUploadFolder] = useState<string>("");
140
+ const [showNewFolderModal, setShowNewFolderModal] = useState(false);
141
+ const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
142
+ const [folderToDelete, setFolderToDelete] = useState<string>("");
143
+
144
+ const [page, setPage] = useState(1);
145
+ const [hasMore, setHasMore] = useState(true);
146
+ const [sortBy, setSortBy] = useState("createdAt");
147
+ const [sortDir, setSortDir] = useState("desc");
148
+ const observerTarget = useRef<HTMLDivElement>(null);
149
+ const [crop, setCrop] = useState<Crop>();
150
+ const [showCrop, setShowCrop] = useState(false);
151
+ const [copiedId, setCopiedId] = useState<string | null>(null);
152
+ const [showStorageConfigModal, setShowStorageConfigModal] = useState(false);
153
+ const [storageConfigured, setStorageConfigured] = useState(true);
154
+ const [deleteConfirm, setDeleteConfirm] = useState<{
155
+ open: boolean;
156
+ count: number;
157
+ ids?: string[];
158
+ }>({ open: false, count: 0 });
159
+ const imgRef = useRef<HTMLImageElement>(null);
160
+
161
+ const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
162
+ const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
163
+ const defaultCrop = centerCrop(
164
+ makeAspectCrop({ unit: "%", width: 90 }, width / height, width, height),
165
+ width,
166
+ height,
167
+ );
168
+ setCrop(defaultCrop);
169
+ };
170
+
171
+ const openMetadataPanel = (item: MediaItem) => {
172
+ setPanelItem(item);
173
+ setPanelName(item.title ?? item.filename);
174
+ setPanelAlt(item.alt ?? "");
175
+ };
176
+
177
+ const loadFolders = useCallback(async () => {
178
+ try {
179
+ const resp = await fetch("/api/media/folders", {
180
+ credentials: "include",
181
+ });
182
+ const data = await resp.json();
183
+ setAvailableFolders((data.folders || []).sort());
184
+ } catch (e) {
185
+ console.error("loadFolders error:", e);
186
+ }
187
+ }, []);
188
+
189
+ const loadMedia = useCallback(
190
+ async (reset = false) => {
191
+ try {
192
+ if (reset) setLoading(true);
193
+ const currentPage = reset ? 1 : page;
194
+ const typeParam = filter !== "all" ? `&type=${filter}` : "";
195
+ const searchParam = searchQuery
196
+ ? `&search=${encodeURIComponent(searchQuery)}`
197
+ : "";
198
+ const folderParam = currentFolder
199
+ ? `&folder=${encodeURIComponent(currentFolder)}`
200
+ : "";
201
+
202
+ const response = await fetch(
203
+ `/api/media?limit=30&page=${currentPage}&sortBy=${sortBy}&sortDir=${sortDir}${typeParam}${searchParam}${folderParam}&t=${Date.now()}`,
204
+ );
205
+ if (!response.ok) throw new Error("Failed to load media");
206
+ const result = await response.json();
207
+
208
+ if (reset) {
209
+ setItems(result.docs || []);
210
+ } else {
211
+ setItems((prev) => {
212
+ const existingIds = new Set(prev.map((i) => i.id));
213
+ const newItems = (result.docs || []).filter(
214
+ (i: any) => !existingIds.has(i.id),
215
+ );
216
+ return [...prev, ...newItems];
217
+ });
218
+ }
219
+ setHasMore(result.page < result.totalPages);
220
+ if (reset) setPage(2);
221
+ else setPage((p) => p + 1);
222
+ } catch (error) {
223
+ console.error("Failed to load media:", error);
224
+ } finally {
225
+ if (reset) setLoading(false);
226
+ }
227
+ },
228
+ [page, filter, searchQuery, currentFolder, sortBy, sortDir],
229
+ );
230
+
231
+ useEffect(() => {
232
+ // Check if storage settings are configured
233
+ fetch("/api/storage-status")
234
+ .then((res) => res.json())
235
+ .then((status) => {
236
+ // If not configured, show modal to configure
237
+ if (!status.configured) {
238
+ setStorageConfigured(false);
239
+ setShowStorageConfigModal(true);
240
+ }
241
+ })
242
+ .catch(() => {
243
+ setStorageConfigured(false);
244
+ setShowStorageConfigModal(true);
245
+ });
246
+ }, []);
247
+
248
+ useEffect(() => {
249
+ loadMedia(true);
250
+ }, [filter, searchQuery, currentFolder, sortBy, sortDir]);
251
+
252
+ useEffect(() => {
253
+ loadFolders();
254
+ }, [loadFolders]);
255
+
256
+ useEffect(() => {
257
+ const observer = new IntersectionObserver(
258
+ (entries) => {
259
+ if (entries[0].isIntersecting && hasMore && !loading) {
260
+ loadMedia();
261
+ }
262
+ },
263
+ { rootMargin: "200px" },
264
+ );
265
+ if (observerTarget.current) observer.observe(observerTarget.current);
266
+ return () => observer.disconnect();
267
+ }, [hasMore, loadMedia, loading]);
268
+
269
+ const handleUpload = async (files: FileList) => {
270
+ setUploading(true);
271
+ setUploadProgress(0);
272
+ setUploadQueue([]);
273
+ const uploaded: MediaItem[] = [];
274
+ const fileArray = Array.from(files);
275
+
276
+ setUploadQueue(
277
+ fileArray.map((f, idx) => ({
278
+ name: f.name,
279
+ idx,
280
+ progress: 0,
281
+ controller: undefined,
282
+ })),
283
+ );
284
+
285
+ for (let i = 0; i < fileArray.length; i++) {
286
+ const file = fileArray[i];
287
+ try {
288
+ const ac = new AbortController();
289
+ setUploadQueue((prev) =>
290
+ prev.map((q) => (q.idx === i ? { ...q, controller: ac } : q)),
291
+ );
292
+ const formData = new FormData();
293
+ formData.append("file", file);
294
+ if (uploadFolder) formData.append("folder", uploadFolder);
295
+
296
+ const response = await fetch("/api/upload", {
297
+ method: "POST",
298
+ body: formData,
299
+ signal: ac.signal,
300
+ credentials: "include",
301
+ });
302
+ if (!response.ok) continue;
303
+
304
+ const result = await response.json();
305
+ const newItem: MediaItem = {
306
+ ...result,
307
+ type: getFileType(result.mimeType || file.type) as MediaItem["type"],
308
+ };
309
+ uploaded.push(newItem);
310
+ } catch (error) {
311
+ console.error(`Error uploading ${file.name}:`, error);
312
+ }
313
+ const pct = Math.round(((i + 1) / fileArray.length) * 100);
314
+ setUploadProgress(pct);
315
+ setUploadQueue((prev) =>
316
+ prev.map((q) => (q.idx === i ? { ...q, progress: 100 } : q)),
317
+ );
318
+ }
319
+
320
+ if (uploaded.length > 0) loadMedia(true);
321
+ setUploading(false);
322
+ setUploadProgress(0);
323
+ setUploadQueue([]);
324
+ };
325
+
326
+ const handleDelete = async (id: string) => {
327
+ setDeleteConfirm({ open: true, count: 1, ids: [id] });
328
+ };
329
+
330
+ const confirmDelete = async () => {
331
+ const { ids } = deleteConfirm;
332
+ if (!ids?.length) return;
333
+ try {
334
+ for (const id of ids) {
335
+ await fetch(`/api/media/${id}`, { method: "DELETE" });
336
+ }
337
+ loadMedia(true);
338
+ setSelectedItems(new Set());
339
+ } catch {}
340
+ setDeleteConfirm({ open: false, count: 0 });
341
+ };
342
+
343
+ const handleBulkDelete = async () => {
344
+ if (selectedItems.size === 0) return;
345
+ setDeleteConfirm({
346
+ open: true,
347
+ count: selectedItems.size,
348
+ ids: Array.from(selectedItems),
349
+ });
350
+ };
351
+
352
+ const toggleSelect = (id: string) => {
353
+ setSelectedItems((prev) => {
354
+ const next = new Set(prev);
355
+ if (next.has(id)) next.delete(id);
356
+ else next.add(id);
357
+ return next;
358
+ });
359
+ };
360
+
361
+ const selectAll = () => {
362
+ if (selectedItems.size === items.length) setSelectedItems(new Set());
363
+ else setSelectedItems(new Set(items.map((item) => item.id)));
364
+ };
365
+
366
+ const cancelUpload = (idx: number) => {
367
+ setUploadQueue((prev) => {
368
+ const target = prev.find((p) => p.idx === idx);
369
+ if (target && (target as any).controller) {
370
+ try {
371
+ (target as any).controller.abort();
372
+ } catch {}
373
+ }
374
+ return prev.filter((p) => p.idx !== idx);
375
+ });
376
+ };
377
+
378
+ const handleDrop = (e: React.DragEvent) => {
379
+ e.preventDefault();
380
+ setUploadDragOver(false);
381
+ if (e.dataTransfer.files.length > 0) {
382
+ handleUpload(e.dataTransfer.files);
383
+ }
384
+ };
385
+
386
+ const handleDragOver = (e: React.DragEvent) => {
387
+ e.preventDefault();
388
+ setUploadDragOver(true);
389
+ };
390
+
391
+ const handleDragLeave = () => {
392
+ setUploadDragOver(false);
393
+ };
394
+
395
+ const handleMoveSelected = async (targetFolder: string) => {
396
+ try {
397
+ setLoading(true);
398
+ const ids = Array.from(selectedItems);
399
+ for (const id of ids) {
400
+ await fetch(`/api/media/${id}`, {
401
+ method: "PATCH",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({ folder: targetFolder }),
404
+ });
405
+ }
406
+ setSelectedItems(new Set());
407
+ loadMedia(true);
408
+ loadFolders();
409
+ } catch (e) {
410
+ console.error("Move error:", e);
411
+ } finally {
412
+ setLoading(false);
413
+ }
414
+ };
415
+
416
+ const createFolder = async (name: string) => {
417
+ if (!name) return;
418
+ try {
419
+ const resp = await fetch("/api/media/folders", {
420
+ method: "POST",
421
+ headers: { "Content-Type": "application/json" },
422
+ body: JSON.stringify({ name, parentPath: currentFolder || "" }),
423
+ credentials: "include",
424
+ });
425
+ if (resp.ok) {
426
+ const newPath = currentFolder ? `${currentFolder}/${name}` : name;
427
+ setCurrentFolder(newPath);
428
+ setUploadFolder(newPath);
429
+ loadFolders();
430
+ }
431
+ } catch (e) {
432
+ console.error("Failed to create folder:", e);
433
+ }
434
+ setShowNewFolderModal(false);
435
+ };
436
+
437
+ const confirmDeleteFolder = (folder: string) => {
438
+ setFolderToDelete(folder);
439
+ setShowDeleteFolderModal(true);
440
+ };
441
+
442
+ const deleteFolder = async () => {
443
+ if (!folderToDelete) return;
444
+ try {
445
+ const resp = await fetch(
446
+ `/api/media/folders?path=${encodeURIComponent(folderToDelete)}`,
447
+ {
448
+ method: "DELETE",
449
+ credentials: "include",
450
+ },
451
+ );
452
+ console.log("[deleteFolder] Response status:", resp.status);
453
+ const result = await resp.json();
454
+ console.log("[deleteFolder] Response:", result);
455
+ if (resp.ok) {
456
+ // Clear items first, then reload
457
+ setItems([]);
458
+ if (currentFolder === folderToDelete) {
459
+ setCurrentFolder("");
460
+ }
461
+ loadFolders();
462
+ // Force fresh load by resetting page
463
+ setPage(1);
464
+ loadMedia(true);
465
+ }
466
+ } catch (e) {
467
+ console.error("Failed to delete folder:", e);
468
+ }
469
+ setShowDeleteFolderModal(false);
470
+ setFolderToDelete("");
471
+ };
472
+
473
+ const savePanelMetadata = async () => {
474
+ if (!panelItem) return;
475
+ try {
476
+ const resp = await fetch(`/api/media/${panelItem.id}`, {
477
+ method: "PATCH",
478
+ headers: { "Content-Type": "application/json" },
479
+ body: JSON.stringify({ title: panelName, alt: panelAlt }),
480
+ });
481
+ if (resp.ok) {
482
+ setPanelItem(null);
483
+ loadMedia(true);
484
+ }
485
+ } catch (e) {
486
+ console.error("Failed to save metadata:", e);
487
+ }
488
+ };
489
+
490
+ const onCropComplete = async () => {
491
+ if (!imgRef.current || !crop || !panelItem) return;
492
+
493
+ // Convert any percentage crops into standard pixels
494
+ const pixelCrop = convertToPixelCrop(
495
+ crop,
496
+ imgRef.current.width,
497
+ imgRef.current.height,
498
+ );
499
+
500
+ const canvas = document.createElement("canvas");
501
+ const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
502
+ const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
503
+
504
+ // Set actual canvas geometry to the exact pixel width * scale
505
+ canvas.width = pixelCrop.width * scaleX;
506
+ canvas.height = pixelCrop.height * scaleY;
507
+
508
+ const ctx = canvas.getContext("2d");
509
+ if (!ctx) return;
510
+
511
+ ctx.drawImage(
512
+ imgRef.current,
513
+ pixelCrop.x * scaleX,
514
+ pixelCrop.y * scaleY,
515
+ pixelCrop.width * scaleX,
516
+ pixelCrop.height * scaleY,
517
+ 0,
518
+ 0,
519
+ pixelCrop.width * scaleX,
520
+ pixelCrop.height * scaleY,
521
+ );
522
+
523
+ canvas.toBlob(
524
+ async (blob) => {
525
+ if (!blob) return;
526
+ const formData = new FormData();
527
+ const newFile = new File([blob], panelItem.filename, {
528
+ type: "image/webp",
529
+ });
530
+ formData.append("file", newFile);
531
+
532
+ setUploading(true);
533
+ try {
534
+ const res = await fetch("/api/upload", {
535
+ method: "POST",
536
+ body: formData,
537
+ credentials: "include",
538
+ });
539
+ if (res.ok) {
540
+ setShowCrop(false);
541
+ setPanelItem(null);
542
+ loadMedia(true);
543
+ }
544
+ } catch (e) {
545
+ console.error("Crop save failed", e);
546
+ } finally {
547
+ setUploading(false);
548
+ }
549
+ },
550
+ "image/webp",
551
+ 0.9,
552
+ );
553
+ };
554
+
555
+ const totalSize = items.reduce((acc, item) => acc + (item.fileSize || 0), 0);
556
+
557
+ const FilterButton = ({
558
+ type,
559
+ label,
560
+ }: {
561
+ type: FilterType;
562
+ label: string;
563
+ }) => (
564
+ <button type="button"
565
+ onClick={() => setFilter(type)}
566
+ className={`px-4 py-2 text-sm font-bold rounded-lg transition-colors ${
567
+ filter === type
568
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
569
+ : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
570
+ }`}
571
+ >
572
+ {label}
573
+ </button>
574
+ );
575
+
576
+ return (
577
+ <div className="h-full flex flex-col bg-[var(--kyro-bg)] rounded-xl overflow-hidden relative">
578
+ {/* Floating Bulk Actions Bar */}
579
+ {selectedItems.size > 0 && (
580
+ <div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-[100] animate-in slide-in-from-bottom-10 fade-in duration-500">
581
+ <div className="surface-tile py-4 px-8 flex items-center gap-8 shadow-2xl border-none ring-1 ring-white/10 backdrop-blur-2xl bg-black/80 text-white rounded-full">
582
+ <div className="flex items-center gap-4 border-r border-white/10 pr-6 mr-6">
583
+ <Badge className="bg-[var(--kyro-primary)] text-white">
584
+ {selectedItems.size}
585
+ </Badge>
586
+ <span className="text-xs font-black uppercase tracking-widest opacity-60">
587
+ Items Selected
588
+ </span>
589
+ </div>
590
+
591
+ <div className="flex items-center gap-4 px-4">
592
+ <button type="button"
593
+ onClick={handleBulkDelete}
594
+ className="p-3 bg-[var(--kyro-danger-bg)] text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger)] hover:text-white rounded-full transition-all"
595
+ title="Delete Selected"
596
+ >
597
+ <Trash2 className="w-5 h-5" />
598
+ </button>
599
+ <button type="button"
600
+ className="p-3 bg-white/10 text-white hover:bg-white/20 rounded-full transition-all"
601
+ title="Download Collection"
602
+ >
603
+ <Download className="w-5 h-5" />
604
+ </button>
605
+ </div>
606
+
607
+ <button type="button"
608
+ onClick={() => setSelectedItems(new Set())}
609
+ className="p-2 hover:bg-white/10 rounded-full transition-all"
610
+ >
611
+ <X className="w-4 h-4" />
612
+ </button>
613
+ </div>
614
+ </div>
615
+ )}
616
+
617
+ <div className="flex-shrink-0 p-8 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
618
+ <div className="flex items-center justify-between mb-8">
619
+ <div>
620
+ <h1 className="text-2xl font-black text-[var(--kyro-text-primary)]">
621
+ Media Library
622
+ </h1>
623
+ <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
624
+ {items.length} files &middot; {formatFileSize(totalSize)} used
625
+ </p>
626
+ </div>
627
+ <div className="flex items-center gap-3">
628
+ <button type="button"
629
+ onClick={() => setViewMode("grid")}
630
+ className={`p-2 rounded-lg ${viewMode === "grid" ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"}`}
631
+ >
632
+ <svg
633
+ className="w-5 h-5"
634
+ fill="none"
635
+ stroke="currentColor"
636
+ viewBox="0 0 24 24"
637
+ >
638
+ <path
639
+ strokeLinecap="round"
640
+ strokeLinejoin="round"
641
+ strokeWidth={2}
642
+ d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
643
+ />
644
+ </svg>
645
+ </button>
646
+ <button type="button"
647
+ onClick={() => setViewMode("list")}
648
+ className={`p-2 rounded-lg ${viewMode === "list" ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"}`}
649
+ >
650
+ <svg
651
+ className="w-5 h-5"
652
+ fill="none"
653
+ stroke="currentColor"
654
+ viewBox="0 0 24 24"
655
+ >
656
+ <path
657
+ strokeLinecap="round"
658
+ strokeLinejoin="round"
659
+ strokeWidth={2}
660
+ d="M4 6h16M4 12h16M4 18h16"
661
+ />
662
+ </svg>
663
+ </button>
664
+ </div>
665
+ </div>
666
+
667
+ <div className="flex flex-col sm:flex-row gap-4 mb-4 items-start sm:items-center w-full">
668
+ <button type="button"
669
+ onClick={() => fileInputRef.current?.click()}
670
+ disabled={uploading}
671
+ className="flex items-center gap-2 px-3 py-1.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-full font-bold text-xs hover:opacity-90 transition-colors"
672
+ >
673
+ {uploading ? (
674
+ <Spinner size="sm" />
675
+ ) : (
676
+ <svg
677
+ className="w-4 h-4"
678
+ fill="none"
679
+ stroke="currentColor"
680
+ viewBox="0 0 24 24"
681
+ >
682
+ <path
683
+ strokeLinecap="round"
684
+ strokeLinejoin="round"
685
+ strokeWidth={2}
686
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
687
+ />
688
+ </svg>
689
+ )}
690
+ Upload
691
+ </button>
692
+ {availableFolders.length > 0 && (
693
+ <div className="flex bg-[var(--kyro-surface-accent)] rounded-full p-1 gap-1 overflow-x-auto items-center">
694
+ <button type="button"
695
+ onClick={() => setCurrentFolder("")}
696
+ className={`flex-shrink-0 px-3 py-1 rounded-full text-xs font-bold transition-colors ${!currentFolder ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-sm" : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)]"}`}
697
+ >
698
+ All
699
+ </button>
700
+ {availableFolders.map((folder) => (
701
+ <div key={folder} className="flex items-center group">
702
+ <button type="button"
703
+ onClick={() => setCurrentFolder(folder)}
704
+ className={`flex-shrink-0 px-3 py-1 rounded-full text-xs font-bold transition-colors ${currentFolder === folder ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-sm" : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)]"}`}
705
+ >
706
+ {folder}
707
+ </button>
708
+ <button type="button"
709
+ onClick={() => confirmDeleteFolder(folder)}
710
+ className="flex-shrink-0 p-1 rounded-full text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] opacity-0 group-hover:opacity-100 transition-opacity ml-1"
711
+ title="Delete folder"
712
+ >
713
+ <svg
714
+ className="w-3 h-3"
715
+ fill="none"
716
+ stroke="currentColor"
717
+ viewBox="0 0 24 24"
718
+ >
719
+ <path
720
+ strokeLinecap="round"
721
+ strokeLinejoin="round"
722
+ strokeWidth={2}
723
+ d="M6 18L18 6M6 6l12 12"
724
+ />
725
+ </svg>
726
+ </button>
727
+ </div>
728
+ ))}
729
+ </div>
730
+ )}
731
+ <button type="button"
732
+ onClick={() => setShowNewFolderModal(true)}
733
+ className="flex items-center gap-2 px-3 py-1.5 border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded-full font-bold text-xs hover:bg-[var(--kyro-surface-accent)] transition-colors"
734
+ >
735
+ <FolderPlus className="w-4 h-4" />
736
+ New Folder
737
+ </button>
738
+ <select
739
+ value={uploadFolder}
740
+ onChange={(e) => setUploadFolder(e.target.value)}
741
+ className="px-2 py-1.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-full text-xs font-bold"
742
+ >
743
+ <option value="">Root</option>
744
+ {availableFolders.map((folder) => (
745
+ <option key={folder} value={folder}>
746
+ {folder}
747
+ </option>
748
+ ))}
749
+ </select>
750
+ <select
751
+ value={`${sortBy}-${sortDir}`}
752
+ onChange={(e) => {
753
+ const [sb, sd] = e.target.value.split("-");
754
+ setSortBy(sb);
755
+ setSortDir(sd);
756
+ }}
757
+ className="px-2 py-1.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-full text-xs font-bold ml-auto"
758
+ >
759
+ <option value="createdAt-desc">Newest First</option>
760
+ <option value="createdAt-asc">Oldest First</option>
761
+ <option value="size-desc">Largest Size</option>
762
+ <option value="size-asc">Smallest Size</option>
763
+ <option value="name-asc">Name (A-Z)</option>
764
+ <option value="name-desc">Name (Z-A)</option>
765
+ </select>
766
+ </div>
767
+
768
+ <div className="relative mb-4">
769
+ <svg
770
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]"
771
+ fill="none"
772
+ stroke="currentColor"
773
+ viewBox="0 0 24 24"
774
+ >
775
+ <path
776
+ strokeLinecap="round"
777
+ strokeLinejoin="round"
778
+ strokeWidth={2}
779
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
780
+ />
781
+ </svg>
782
+ <input
783
+ type="text"
784
+ value={searchQuery}
785
+ onChange={(e) => setSearchQuery(e.target.value)}
786
+ placeholder="Search files..."
787
+ className="w-full pl-10 pr-4 py-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-sm focus:outline-none focus:border-[var(--kyro-border-active)]"
788
+ />
789
+ </div>
790
+
791
+ <div
792
+ className={`flex-shrink-0 mx-6 mt-4 p-8 border-2 border-dashed rounded-xl text-center transition-colors ${
793
+ uploadDragOver
794
+ ? "border-[var(--kyro-border-active)] bg-[var(--kyro-surface-accent)] bg-opacity-20"
795
+ : "border-[var(--kyro-border)] hover:border-[var(--kyro-border-active)] hover:bg-[var(--kyro-surface-accent)]"
796
+ }`}
797
+ onDrop={handleDrop}
798
+ onDragOver={handleDragOver}
799
+ onDragLeave={handleDragLeave}
800
+ onClick={() => fileInputRef.current?.click()}
801
+ >
802
+ <svg
803
+ className={`w-12 h-12 mx-auto mb-3 ${
804
+ uploadDragOver
805
+ ? "text-[var(--kyro-text-primary)]"
806
+ : "text-[var(--kyro-text-muted)]"
807
+ }`}
808
+ fill="none"
809
+ stroke="currentColor"
810
+ viewBox="0 0 24 24"
811
+ >
812
+ <path
813
+ strokeLinecap="round"
814
+ strokeLinejoin="round"
815
+ strokeWidth={1.5}
816
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
817
+ />
818
+ </svg>
819
+ <p className="text-[var(--kyro-text-secondary)] font-medium">
820
+ {uploadDragOver
821
+ ? "Drop files here"
822
+ : "Drag and drop files here, or click Upload"}
823
+ </p>
824
+ <p className="text-xs text-[var(--kyro-text-muted)] mt-1">
825
+ Supports images, videos, audio, documents, and archives
826
+ </p>
827
+ </div>
828
+
829
+ {(uploading || uploadProgress > 0) && (
830
+ <div className="w-full mt-4 flex items-center gap-4">
831
+ <div className="flex-1 h-2 w-full bg-[var(--kyro-surface-accent)] rounded-full overflow-hidden">
832
+ <div
833
+ className="h-2 bg-[var(--kyro-sidebar-active)] transition-all duration-200"
834
+ style={{ width: `${uploadProgress}%` }}
835
+ />
836
+ </div>
837
+ <div className="text-xs text-[var(--kyro-text-secondary)] whitespace-nowrap">
838
+ {uploadQueue.length > 0 ? (
839
+ <span>
840
+ {uploadQueue.filter((q) => q.progress === 100).length} /{" "}
841
+ {uploadQueue.length}
842
+ </span>
843
+ ) : (
844
+ <span>{uploadProgress}%</span>
845
+ )}
846
+ </div>
847
+ {uploadQueue.length > 1 && (
848
+ <span className="text-xs text-[var(--kyro-text-muted)]">
849
+ Uploading {uploadQueue.length} files...
850
+ </span>
851
+ )}
852
+ </div>
853
+ )}
854
+
855
+ <div className="flex items-center gap-2 mt-4">
856
+ <FilterButton type="all" label="All" />
857
+ <FilterButton type="image" label="Images" />
858
+ <FilterButton type="video" label="Videos" />
859
+ <FilterButton type="audio" label="Audio" />
860
+ <FilterButton type="document" label="Documents" />
861
+ <FilterButton type="archive" label="Archives" />
862
+
863
+ {selectedItems.size > 0 && (
864
+ <div className="ml-auto flex items-center gap-3 bg-[var(--kyro-surface-accent)] px-4 py-2 rounded-2xl border border-[var(--kyro-border)] shadow-sm animate-in fade-in slide-in-from-right-4">
865
+ <span className="text-[10px] font-black uppercase tracking-widest opacity-40 mr-2">
866
+ {selectedItems.size} Selected
867
+ </span>
868
+ <button type="button"
869
+ onClick={() => setSelectedItems(new Set())}
870
+ className="text-[10px] font-black uppercase tracking-widest text-[var(--kyro-danger)] hover:underline"
871
+ >
872
+ Clear
873
+ </button>
874
+ </div>
875
+ )}
876
+ </div>
877
+ </div>
878
+
879
+ <div className="flex-1 overflow-y-auto pt-8 pb-32 px-2 bg-[var(--kyro-surface)]">
880
+ {loading ? (
881
+ <div className="flex items-center justify-center h-full">
882
+ <Spinner size="lg" />
883
+ </div>
884
+ ) : items.length === 0 ? (
885
+ <div className="flex flex-col items-center justify-center h-full text-[var(--kyro-text-muted)]">
886
+ <svg
887
+ className="w-16 h-16 mb-4 opacity-50"
888
+ fill="none"
889
+ stroke="currentColor"
890
+ viewBox="0 0 24 24"
891
+ >
892
+ <path
893
+ strokeLinecap="round"
894
+ strokeLinejoin="round"
895
+ strokeWidth={1.5}
896
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
897
+ />
898
+ </svg>
899
+ <p className="font-medium">No media files found</p>
900
+ <p className="text-sm mt-1">Upload some files to get started</p>
901
+ </div>
902
+ ) : viewMode === "grid" ? (
903
+ <div className="columns-2 sm:columns-3 md:columns-4 lg:columns-5 gap-2 space-y-2">
904
+ {items.map((item, index) => (
905
+ <div
906
+ key={index}
907
+ draggable
908
+ onDragStart={(e) => {
909
+ e.dataTransfer.setData("text/plain", item.id);
910
+ e.dataTransfer.effectAllowed = "move";
911
+ }}
912
+ className={`group relative rounded-2xl overflow-hidden bg-[var(--kyro-bg-secondary)] border-2 transition-all duration-500 cursor-move shadow-sm hover:shadow-2xl hover:-translate-y-1 mb-6 break-inside-avoid ${selectedItems.has(item.id) ? "border-[var(--kyro-primary)] ring-2 ring-[var(--kyro-primary)]" : "border-transparent hover:border-[var(--kyro-primary)]/20"}`}
913
+ >
914
+ <div
915
+ className={`${item.type === "image" ? "aspect-auto" : "aspect-square"} flex items-center justify-center cursor-pointer`}
916
+ onClick={() => {
917
+ openMetadataPanel(item);
918
+ }}
919
+ >
920
+ {item.type === "image" && item.thumbnailUrl ? (
921
+ <img
922
+ src={getAbsoluteUrl(item.thumbnailUrl)}
923
+ alt={item.alt || item.title}
924
+ className="w-full h-auto object-cover"
925
+ loading="lazy"
926
+ />
927
+ ) : item.type === "image" ? (
928
+ <img
929
+ src={getAbsoluteUrl(item.url)}
930
+ alt={item.alt || item.title}
931
+ className="w-full h-auto object-cover"
932
+ loading="lazy"
933
+ />
934
+ ) : (
935
+ <div className="w-full h-full flex flex-col items-center justify-center bg-[var(--kyro-surface-accent)] rounded-md text-[var(--kyro-text-muted)] group-hover:text-[var(--kyro-primary)] transition-colors">
936
+ {item.type === "video" ? (
937
+ <Film className="w-12 h-12 mb-2" />
938
+ ) : item.type === "audio" ? (
939
+ <Music className="w-12 h-12 mb-2" />
940
+ ) : item.type === "document" ? (
941
+ <FileText className="w-12 h-12 mb-2" />
942
+ ) : item.type === "archive" ? (
943
+ <Archive className="w-12 h-12 mb-2" />
944
+ ) : (
945
+ <FileIcon className="w-12 h-12 mb-2" />
946
+ )}
947
+ <span className="font-semibold tracking-wider uppercase text-[10px]">
948
+ {getFileExtension(item.filename || item.title)}
949
+ </span>
950
+ </div>
951
+ )}
952
+ </div>
953
+ <button type="button"
954
+ onClick={(e) => {
955
+ e.stopPropagation();
956
+ toggleSelect(item.id);
957
+ }}
958
+ className={`absolute top-2 left-2 z-10 w-6 h-6 rounded-md flex items-center justify-center transition-all ${selectedItems.has(item.id) ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)]" : "bg-black/50 text-white opacity-0 group-hover:opacity-100"}`}
959
+ >
960
+ {selectedItems.has(item.id) && (
961
+ <svg
962
+ className="w-4 h-4"
963
+ fill="none"
964
+ stroke="currentColor"
965
+ viewBox="0 0 24 24"
966
+ >
967
+ <path
968
+ strokeLinecap="round"
969
+ strokeLinejoin="round"
970
+ strokeWidth={2}
971
+ d="M5 13l4 4L19 7"
972
+ />
973
+ </svg>
974
+ )}
975
+ </button>
976
+ <button type="button"
977
+ onClick={(e) => {
978
+ e.stopPropagation();
979
+ openMetadataPanel(item);
980
+ }}
981
+ className="absolute top-2 right-10 z-10 w-6 h-6 rounded-md bg-[var(--kyro-primary)] text-[var(--kyro-sidebar-text-active)] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
982
+ title="Edit metadata"
983
+ >
984
+ <svg
985
+ className="w-4 h-4"
986
+ fill="none"
987
+ stroke="currentColor"
988
+ viewBox="0 0 24 24"
989
+ >
990
+ <path
991
+ strokeLinecap="round"
992
+ strokeLinejoin="round"
993
+ strokeWidth={2}
994
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
995
+ />
996
+ </svg>
997
+ </button>
998
+ <button type="button"
999
+ onClick={(e) => {
1000
+ e.stopPropagation();
1001
+ handleDelete(item.id);
1002
+ }}
1003
+ className="absolute top-2 right-2 z-10 w-6 h-6 rounded-md bg-[var(--kyro-danger)] text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
1004
+ title="Delete"
1005
+ >
1006
+ <svg
1007
+ className="w-4 h-4"
1008
+ fill="none"
1009
+ stroke="currentColor"
1010
+ viewBox="0 0 24 24"
1011
+ >
1012
+ <path
1013
+ strokeLinecap="round"
1014
+ strokeLinejoin="round"
1015
+ strokeWidth={2}
1016
+ d="M6 18L18 6M6 6l12 12"
1017
+ />
1018
+ </svg>
1019
+ </button>
1020
+ <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
1021
+ <p className="text-white text-xs font-medium truncate">
1022
+ {item.title}
1023
+ </p>
1024
+ <p className="text-white/70 text-[10px]">
1025
+ {formatFileSize(item.fileSize)}
1026
+ </p>
1027
+ </div>
1028
+ </div>
1029
+ ))}
1030
+ </div>
1031
+ ) : (
1032
+ <div className="space-y-2">
1033
+ <div className="flex items-center gap-4 px-4 py-2 text-xs font-bold text-[var(--kyro-text-muted)] uppercase">
1034
+ <button type="button"
1035
+ onClick={selectAll}
1036
+ className="w-6 h-6 rounded border border-[var(--kyro-border)] flex items-center justify-center hover:bg-[var(--kyro-surface-accent)]"
1037
+ >
1038
+ {selectedItems.size === items.length && items.length > 0 && (
1039
+ <svg
1040
+ className="w-4 h-4"
1041
+ fill="none"
1042
+ stroke="currentColor"
1043
+ viewBox="0 0 24 24"
1044
+ >
1045
+ <path
1046
+ strokeLinecap="round"
1047
+ strokeLinejoin="round"
1048
+ strokeWidth={2}
1049
+ d="M5 13l4 4L19 7"
1050
+ />
1051
+ </svg>
1052
+ )}
1053
+ </button>
1054
+ <span className="flex-1">Name</span>
1055
+ <span className="w-24 text-right">Type</span>
1056
+ <span className="w-24 text-right">Size</span>
1057
+ <span className="w-32">Date</span>
1058
+ <span className="w-10"></span>
1059
+ </div>
1060
+ {items.map((item) => (
1061
+ <div
1062
+ key={item.id}
1063
+ draggable
1064
+ onDragStart={(e) => {
1065
+ e.dataTransfer.setData("text/plain", item.id);
1066
+ e.dataTransfer.effectAllowed = "move";
1067
+ }}
1068
+ className={`flex items-center gap-4 px-4 py-3 rounded-lg transition-colors cursor-pointer ${selectedItems.has(item.id) ? "bg-[var(--kyro-surface-accent)]" : "hover:bg-[var(--kyro-surface-accent)]"}`}
1069
+ >
1070
+ <button type="button"
1071
+ onClick={() => toggleSelect(item.id)}
1072
+ className={`w-6 h-6 rounded border flex items-center justify-center transition-colors ${selectedItems.has(item.id) ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)]" : "border-[var(--kyro-border)] hover:border-[var(--kyro-border-active)]"}`}
1073
+ >
1074
+ {selectedItems.has(item.id) && (
1075
+ <svg
1076
+ className="w-4 h-4"
1077
+ fill="none"
1078
+ stroke="currentColor"
1079
+ viewBox="0 0 24 24"
1080
+ >
1081
+ <path
1082
+ strokeLinecap="round"
1083
+ strokeLinejoin="round"
1084
+ strokeWidth={2}
1085
+ d="M5 13l4 4L19 7"
1086
+ />
1087
+ </svg>
1088
+ )}
1089
+ </button>
1090
+ <div className="flex-1 flex items-center gap-3 min-w-0">
1091
+ <div
1092
+ className="w-10 h-10 rounded-lg bg-[var(--kyro-surface)] flex items-center justify-center overflow-hidden flex-shrink-0 cursor-pointer"
1093
+ onClick={() => openMetadataPanel(item)}
1094
+ >
1095
+ {item.type === "image" &&
1096
+ (item.thumbnailUrl || item.url) ? (
1097
+ <img
1098
+ src={getAbsoluteUrl(item.thumbnailUrl || item.url)}
1099
+ alt=""
1100
+ className="w-full h-full object-cover"
1101
+ />
1102
+ ) : item.type === "video" ? (
1103
+ <Film className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1104
+ ) : item.type === "audio" ? (
1105
+ <Music className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1106
+ ) : item.type === "document" ? (
1107
+ <FileText className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1108
+ ) : item.type === "archive" ? (
1109
+ <Archive className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1110
+ ) : (
1111
+ <FileIcon className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1112
+ )}
1113
+ </div>
1114
+ <div
1115
+ className="min-w-0 flex-1 cursor-pointer"
1116
+ onClick={() => openMetadataPanel(item)}
1117
+ >
1118
+ <p className="font-medium text-[var(--kyro-text-primary)] truncate">
1119
+ {item.title}
1120
+ </p>
1121
+ <p className="text-xs text-[var(--kyro-text-muted)] truncate">
1122
+ {item.originalName || item.filename}
1123
+ </p>
1124
+ </div>
1125
+ </div>
1126
+ <span className="w-24 text-right text-sm text-[var(--kyro-text-secondary)] capitalize">
1127
+ {item.type}
1128
+ </span>
1129
+ <span className="w-24 text-right text-sm text-[var(--kyro-text-secondary)]">
1130
+ {formatFileSize(item.fileSize)}
1131
+ </span>
1132
+ <span className="w-32 text-sm text-[var(--kyro-text-muted)]">
1133
+ {formatDate(item.createdAt)}
1134
+ </span>
1135
+ <button type="button"
1136
+ onClick={(e) => {
1137
+ e.stopPropagation();
1138
+ openMetadataPanel(item);
1139
+ }}
1140
+ className="w-10 h-10 rounded-lg hover:bg-[var(--kyro-primary)]/10 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-primary)] flex items-center justify-center transition-colors"
1141
+ title="Edit details"
1142
+ >
1143
+ <svg
1144
+ className="w-4 h-4"
1145
+ fill="none"
1146
+ stroke="currentColor"
1147
+ viewBox="0 0 24 24"
1148
+ >
1149
+ <path
1150
+ strokeLinecap="round"
1151
+ strokeLinejoin="round"
1152
+ strokeWidth={1.5}
1153
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
1154
+ />
1155
+ </svg>
1156
+ </button>
1157
+ <button type="button"
1158
+ onClick={() => handleDelete(item.id)}
1159
+ className="w-10 h-10 rounded-lg hover:bg-[var(--kyro-danger-bg)] text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] flex items-center justify-center transition-colors"
1160
+ >
1161
+ <svg
1162
+ className="w-5 h-5"
1163
+ fill="none"
1164
+ stroke="currentColor"
1165
+ viewBox="0 0 24 24"
1166
+ >
1167
+ <path
1168
+ strokeLinecap="round"
1169
+ strokeLinejoin="round"
1170
+ strokeWidth={1.5}
1171
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
1172
+ />
1173
+ </svg>
1174
+ </button>
1175
+ </div>
1176
+ ))}
1177
+ </div>
1178
+ )}
1179
+
1180
+ {/* Infinite Scroll Observer Target */}
1181
+ {hasMore && !loading && (
1182
+ <div
1183
+ ref={observerTarget}
1184
+ className="h-20 w-full flex items-center justify-center mt-4"
1185
+ >
1186
+ <Spinner size="sm" />
1187
+ </div>
1188
+ )}
1189
+ </div>
1190
+
1191
+ {lightboxItem &&
1192
+ createPortal(
1193
+ <div
1194
+ className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center p-8 animate-in fade-in duration-200"
1195
+ onClick={() => setLightboxItem(null)}
1196
+ >
1197
+ <button type="button"
1198
+ onClick={() => setLightboxItem(null)}
1199
+ className="absolute top-4 right-4 w-12 h-12 rounded-full bg-white/10 text-white flex items-center justify-center hover:bg-white/20 transition-colors"
1200
+ >
1201
+ <svg
1202
+ className="w-6 h-6"
1203
+ fill="none"
1204
+ stroke="currentColor"
1205
+ viewBox="0 0 24 24"
1206
+ >
1207
+ <path
1208
+ strokeLinecap="round"
1209
+ strokeLinejoin="round"
1210
+ strokeWidth={2}
1211
+ d="M6 18L18 6M6 6l12 12"
1212
+ />
1213
+ </svg>
1214
+ </button>
1215
+ <div className="absolute top-4 left-4 text-white">
1216
+ <p className="font-bold text-lg">{lightboxItem.title}</p>
1217
+ <p className="text-sm text-white/60">{lightboxItem.filename}</p>
1218
+ <p className="text-xs text-white/40 mt-1">
1219
+ {formatFileSize(lightboxItem.fileSize)}
1220
+ </p>
1221
+ </div>
1222
+ <img
1223
+ src={getAbsoluteUrl(lightboxItem.url)}
1224
+ alt={lightboxItem.alt || lightboxItem.title}
1225
+ className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
1226
+ onClick={(e) => e.stopPropagation()}
1227
+ />
1228
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4">
1229
+ <a
1230
+ href={getAbsoluteUrl(lightboxItem.url)}
1231
+ target="_blank"
1232
+ rel="noopener noreferrer"
1233
+ className="px-4 py-2 bg-white/10 text-white rounded-lg font-bold text-sm hover:bg-white/20 transition-colors"
1234
+ onClick={(e) => e.stopPropagation()}
1235
+ >
1236
+ Open in new tab
1237
+ </a>
1238
+ <button type="button"
1239
+ onClick={(e) => {
1240
+ e.stopPropagation();
1241
+ navigator.clipboard.writeText(
1242
+ getAbsoluteUrl(lightboxItem.url),
1243
+ );
1244
+ setCopiedId("lightbox");
1245
+ setTimeout(() => setCopiedId(null), 2000);
1246
+ }}
1247
+ className="px-4 py-2 bg-white/10 text-white rounded-lg font-bold text-sm hover:bg-white/20 transition-colors"
1248
+ >
1249
+ {copiedId === "lightbox" ? "Copied!" : "Copy URL"}
1250
+ </button>
1251
+ </div>
1252
+ </div>,
1253
+ document.body,
1254
+ )}
1255
+
1256
+ {panelItem && (
1257
+ <div className="fixed inset-0 z-50 flex justify-end">
1258
+ <div
1259
+ className="absolute inset-0 bg-black/30"
1260
+ onClick={() => setPanelItem(null)}
1261
+ />
1262
+ <div className="relative w-100 h-full bg-[var(--kyro-surface)] border-l border-[var(--kyro-border)] shadow-xl p-6 flex flex-col">
1263
+ <div className="flex items-center justify-between mb-6">
1264
+ <h2 className="text-lg font-bold text-[var(--kyro-text-primary)]">
1265
+ Media Details
1266
+ </h2>
1267
+ <button type="button"
1268
+ onClick={() => setPanelItem(null)}
1269
+ className="p-2 rounded-lg hover:bg-[var(--kyro-surface-accent)]"
1270
+ >
1271
+ <svg
1272
+ className="w-5 h-5 text-[var(--kyro-text-secondary)]"
1273
+ fill="none"
1274
+ stroke="currentColor"
1275
+ viewBox="0 0 24 24"
1276
+ >
1277
+ <path
1278
+ strokeLinecap="round"
1279
+ strokeLinejoin="round"
1280
+ strokeWidth={2}
1281
+ d="M6 18L18 6M6 6l12 12"
1282
+ />
1283
+ </svg>
1284
+ </button>
1285
+ </div>
1286
+ <div className="aspect-square bg-[var(--kyro-surface-accent)] rounded-lg mb-6 flex items-center justify-center overflow-hidden">
1287
+ {panelItem.type === "image" ? (
1288
+ <img
1289
+ src={getAbsoluteUrl(panelItem.thumbnailUrl || panelItem.url)}
1290
+ alt=""
1291
+ className="w-full h-full object-contain"
1292
+ />
1293
+ ) : (
1294
+ <div className="w-full h-full flex flex-col items-center justify-center bg-[var(--kyro-surface-accent)] rounded-md text-[var(--kyro-text-muted)]">
1295
+ {panelItem.type === "video" ? (
1296
+ <Film className="w-24 h-24 mb-4 opacity-50" />
1297
+ ) : panelItem.type === "audio" ? (
1298
+ <Music className="w-24 h-24 mb-4 opacity-50" />
1299
+ ) : panelItem.type === "document" ? (
1300
+ <FileText className="w-24 h-24 mb-4 opacity-50" />
1301
+ ) : panelItem.type === "archive" ? (
1302
+ <Archive className="w-24 h-24 mb-4 opacity-50" />
1303
+ ) : (
1304
+ <FileIcon className="w-24 h-24 mb-4 opacity-50" />
1305
+ )}
1306
+ <span className="text-xl font-bold tracking-wider uppercase text-[var(--kyro-text-secondary)]">
1307
+ {getFileExtension(panelItem.filename)}
1308
+ </span>
1309
+ </div>
1310
+ )}
1311
+ </div>
1312
+ <div className="space-y-4 flex-1">
1313
+ <div>
1314
+ <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
1315
+ Display Name
1316
+ </label>
1317
+ <input
1318
+ type="text"
1319
+ value={panelName}
1320
+ onChange={(e) => setPanelName(e.target.value)}
1321
+ className="w-full px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-border-active)]"
1322
+ />
1323
+ </div>
1324
+ <div>
1325
+ <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
1326
+ Alt Text
1327
+ </label>
1328
+ <input
1329
+ type="text"
1330
+ value={panelAlt}
1331
+ onChange={(e) => setPanelAlt(e.target.value)}
1332
+ placeholder="Accessibility text..."
1333
+ className="w-full px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:border-[var(--kyro-border-active)]"
1334
+ />
1335
+ </div>
1336
+ <div className="pt-4 border-t border-[var(--kyro-border)] space-y-2 text-xs text-[var(--kyro-text-muted)]">
1337
+ <p>
1338
+ <span className="font-bold">Filename:</span>{" "}
1339
+ {panelItem.filename}
1340
+ </p>
1341
+ <p>
1342
+ <span className="font-bold">Type:</span> {panelItem.mimeType}
1343
+ </p>
1344
+ <p>
1345
+ <span className="font-bold">Size:</span>{" "}
1346
+ {formatFileSize(panelItem.fileSize)}
1347
+ </p>
1348
+ <p className="pt-2">
1349
+ <span className="font-bold">File URL</span>
1350
+ </p>
1351
+ <div className="flex gap-2 items-center">
1352
+ <input
1353
+ type="text"
1354
+ readOnly
1355
+ value={getAbsoluteUrl(panelItem.url)}
1356
+ className="flex-1 px-3 py-2 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-lg text-xs text-[var(--kyro-text-muted)] focus:outline-none truncate"
1357
+ />
1358
+ <button type="button"
1359
+ onClick={() => {
1360
+ navigator.clipboard.writeText(
1361
+ getAbsoluteUrl(panelItem.url),
1362
+ );
1363
+ setCopiedId(panelItem.id);
1364
+ setTimeout(() => setCopiedId(null), 2000);
1365
+ }}
1366
+ className={`p-2 border rounded-lg transition-colors shrink-0 ${
1367
+ copiedId === panelItem.id
1368
+ ? "border-[var(--kyro-success)] bg-[var(--kyro-success-bg)] text-[var(--kyro-success)]"
1369
+ : "border-[var(--kyro-border)] hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
1370
+ }`}
1371
+ title={copiedId === panelItem.id ? "Copied!" : "Copy URL"}
1372
+ >
1373
+ <Link className="w-4 h-4" />
1374
+ </button>
1375
+ <a
1376
+ href={getAbsoluteUrl(panelItem.url)}
1377
+ download={panelItem.filename}
1378
+ target="_blank"
1379
+ rel="noopener noreferrer"
1380
+ className="p-2 border border-[var(--kyro-border)] rounded-lg hover:bg-[var(--kyro-surface-accent)] transition-colors shrink-0"
1381
+ title="Download"
1382
+ >
1383
+ <Download className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
1384
+ </a>
1385
+ </div>
1386
+ <p>
1387
+ <span className="font-bold">Uploaded:</span>{" "}
1388
+ {formatDate(panelItem.createdAt)}
1389
+ </p>
1390
+ </div>
1391
+ {panelItem.type === "image" && (
1392
+ <button type="button"
1393
+ onClick={() => setShowCrop(true)}
1394
+ className="flex items-center gap-2 px-3 py-1.5 bg-[var(--kyro-surface-accent)] text-[var(--kyro-primary)] rounded-lg font-bold text-xs hover:opacity-80 transition-colors mt-2 w-full"
1395
+ >
1396
+ <CropIcon className="w-3.5 h-3.5" />
1397
+ Crop Image
1398
+ </button>
1399
+ )}
1400
+ </div>
1401
+ <div className="flex gap-3 mt-6">
1402
+ <a
1403
+ href={getAbsoluteUrl(panelItem.url)}
1404
+ download={panelItem.filename}
1405
+ target="_blank"
1406
+ rel="noopener noreferrer"
1407
+ className="p-2 border border-[var(--kyro-border)] rounded-lg hover:bg-[var(--kyro-surface-accent)] transition-colors"
1408
+ title="Download"
1409
+ >
1410
+ <Download className="w-5 h-5 text-[var(--kyro-text-secondary)]" />
1411
+ </a>
1412
+ <button type="button"
1413
+ onClick={savePanelMetadata}
1414
+ className="flex-1 py-2 px-4 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-lg font-bold text-sm hover:opacity-90"
1415
+ >
1416
+ Save
1417
+ </button>
1418
+ <button type="button"
1419
+ onClick={() => setPanelItem(null)}
1420
+ className="py-2 px-4 border border-[var(--kyro-border)] rounded-lg font-bold text-sm text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
1421
+ >
1422
+ Cancel
1423
+ </button>
1424
+ </div>
1425
+ </div>
1426
+ </div>
1427
+ )}
1428
+
1429
+ {showCrop &&
1430
+ panelItem &&
1431
+ createPortal(
1432
+ <div className="fixed inset-0 z-[10000] bg-black/95 flex flex-col items-center justify-center p-8">
1433
+ <div className="flex w-full justify-between items-center mb-4 text-white">
1434
+ <h3 className="text-xl font-bold">Crop Image</h3>
1435
+ <div className="flex gap-3">
1436
+ <button type="button"
1437
+ onClick={() => setShowCrop(false)}
1438
+ className="px-4 py-2 border border-white/20 text-white/80 hover:bg-white/10 rounded-lg font-bold text-sm transition-colors"
1439
+ >
1440
+ Cancel
1441
+ </button>
1442
+ <button type="button"
1443
+ disabled={uploading}
1444
+ onClick={onCropComplete}
1445
+ className="px-4 py-2 bg-[var(--kyro-sidebar-active)] hover:opacity-90 text-[var(--kyro-sidebar-text-active)] rounded-lg font-bold text-sm transition-colors"
1446
+ >
1447
+ {uploading ? "Saving..." : "Save Crop"}
1448
+ </button>
1449
+ </div>
1450
+ </div>
1451
+ <div className="flex-1 w-full flex items-center justify-center overflow-auto">
1452
+ <ReactCrop crop={crop} onChange={(c) => setCrop(c)}>
1453
+ <img
1454
+ ref={imgRef}
1455
+ src={`/api/media/resize?url=${encodeURIComponent(panelItem.url)}`}
1456
+ alt="Crop preview"
1457
+ className="max-h-[70vh] object-contain"
1458
+ onLoad={onImageLoad}
1459
+ />
1460
+ </ReactCrop>
1461
+ </div>
1462
+ </div>,
1463
+ document.body,
1464
+ )}
1465
+ <ConfirmModal
1466
+ open={deleteConfirm.open}
1467
+ onClose={() => setDeleteConfirm({ open: false, count: 0 })}
1468
+ onConfirm={confirmDelete}
1469
+ title="Delete Media"
1470
+ message={`Are you sure you want to delete ${deleteConfirm.count} item(s)? This cannot be undone.`}
1471
+ confirmLabel="Delete"
1472
+ variant="danger"
1473
+ />
1474
+ <PromptModal
1475
+ open={showNewFolderModal}
1476
+ onClose={() => setShowNewFolderModal(false)}
1477
+ onSubmit={createFolder}
1478
+ title="Create New Folder"
1479
+ placeholder="Folder name"
1480
+ />
1481
+ {showStorageConfigModal &&
1482
+ createPortal(
1483
+ <div className="fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-4">
1484
+ <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-2xl p-8 max-w-md w-full shadow-2xl">
1485
+ <div className="text-center">
1486
+ <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--kyro-sidebar-active)] flex items-center justify-center">
1487
+ <svg
1488
+ className="w-8 h-8 text-[var(--kyro-sidebar-text-active)]"
1489
+ fill="none"
1490
+ stroke="currentColor"
1491
+ viewBox="0 0 24 24"
1492
+ >
1493
+ <path
1494
+ strokeLinecap="round"
1495
+ strokeLinejoin="round"
1496
+ strokeWidth={2}
1497
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2H5a2 2 0 00-2 2v1m2 2a2 2 0 11-4 0 2 2 0 014 0zm2 2h.008v.008H5v-.008z"
1498
+ />
1499
+ </svg>
1500
+ </div>
1501
+ <h3 className="text-xl font-black text-[var(--kyro-text-primary)] mb-2">
1502
+ Storage Not Configured
1503
+ </h3>
1504
+ <p className="text-[var(--kyro-text-secondary)] mb-6 text-sm">
1505
+ Before uploading media, you need to configure your storage
1506
+ settings. Choose where files should be stored and how URLs are
1507
+ generated.
1508
+ </p>
1509
+ <div className="flex gap-3">
1510
+ <a
1511
+ href="/settings/storage-settings"
1512
+ className="flex-1 px-4 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-center hover:opacity-90 transition-colors"
1513
+ >
1514
+ Configure Storage
1515
+ </a>
1516
+ <button type="button"
1517
+ onClick={() => {
1518
+ // Set default storage config programmatically
1519
+ fetch("/api/globals/storage-settings", {
1520
+ method: "PATCH",
1521
+ headers: { "Content-Type": "application/json" },
1522
+ body: JSON.stringify({
1523
+ provider: "local",
1524
+ local: {
1525
+ uploadDir: "./public/uploads",
1526
+ baseUrl: "/uploads",
1527
+ },
1528
+ }),
1529
+ }).then(() => {
1530
+ setShowStorageConfigModal(false);
1531
+ setStorageConfigured(true);
1532
+ window.location.reload();
1533
+ });
1534
+ }}
1535
+ className="flex-1 px-4 py-3 border border-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded-xl font-bold hover:bg-[var(--kyro-surface-accent)] transition-colors"
1536
+ >
1537
+ Use Defaults
1538
+ </button>
1539
+ </div>
1540
+ </div>
1541
+ </div>
1542
+ </div>,
1543
+ document.body,
1544
+ )}
1545
+ <ConfirmModal
1546
+ open={showDeleteFolderModal}
1547
+ onClose={() => {
1548
+ setShowDeleteFolderModal(false);
1549
+ setFolderToDelete("");
1550
+ }}
1551
+ onConfirm={deleteFolder}
1552
+ title="Delete Folder"
1553
+ message={`Are you sure you want to delete the folder "${folderToDelete}"? All media in this folder will be moved to the root.`}
1554
+ confirmLabel="Delete Folder"
1555
+ variant="danger"
1556
+ />
1557
+ <input
1558
+ type="file"
1559
+ ref={fileInputRef}
1560
+ onChange={(e) => {
1561
+ if (e.target.files) handleUpload(e.target.files);
1562
+ }}
1563
+ multiple
1564
+ className="hidden"
1565
+ accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.zip,.rar,.tar"
1566
+ />
1567
+ </div>
1568
+ );
1569
+ }