@kyro-cms/admin 0.3.1 → 0.3.4

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 (242) hide show
  1. package/dist/EditorClient-XEUOVAAC.js +466 -0
  2. package/dist/EditorClient-XEUOVAAC.js.map +1 -0
  3. package/dist/EditorClient-YLCGVDXY.cjs +468 -0
  4. package/dist/EditorClient-YLCGVDXY.cjs.map +1 -0
  5. package/dist/chunk-7KPIUCGT.js +384 -0
  6. package/dist/chunk-7KPIUCGT.js.map +1 -0
  7. package/dist/chunk-GOACG6R7.cjs +473 -0
  8. package/dist/chunk-GOACG6R7.cjs.map +1 -0
  9. package/dist/index.cjs +14861 -0
  10. package/dist/index.cjs.map +1 -0
  11. package/dist/index.css +1661 -0
  12. package/dist/index.css.map +1 -0
  13. package/dist/index.d.ts +563 -0
  14. package/dist/index.js +14784 -0
  15. package/dist/index.js.map +1 -0
  16. package/package.json +19 -19
  17. package/src/components/ActionBar.tsx +7 -43
  18. package/src/components/Admin.tsx +138 -277
  19. package/src/components/ApiKeysManager.tsx +428 -419
  20. package/src/components/AuditLogsPage.tsx +35 -39
  21. package/src/components/AuthBridge.tsx +51 -0
  22. package/src/components/AutoForm.tsx +495 -1230
  23. package/src/components/BrandingHub.tsx +18 -19
  24. package/src/components/BulkActionsBar.tsx +1 -1
  25. package/src/components/CreateView.tsx +22 -36
  26. package/src/components/Dashboard.tsx +60 -84
  27. package/src/components/DetailView.tsx +113 -91
  28. package/src/components/DeveloperCenter.tsx +200 -198
  29. package/src/components/FieldRenderer.tsx +206 -0
  30. package/src/components/GraphQLPlayground.tsx +340 -480
  31. package/src/components/ListView.tsx +828 -254
  32. package/src/components/LoginPage.tsx +3 -4
  33. package/src/components/MarketplaceManager.tsx +254 -0
  34. package/src/components/MediaGallery.tsx +856 -1192
  35. package/src/components/PluginsManager.tsx +277 -0
  36. package/src/components/RestPlayground.tsx +398 -560
  37. package/src/components/SessionsManager.tsx +211 -0
  38. package/src/components/Sidebar.astro +179 -151
  39. package/src/components/ThemeProvider.tsx +7 -161
  40. package/src/components/UserManagement.tsx +162 -146
  41. package/src/components/UserMenu.tsx +110 -0
  42. package/src/components/WebhookManager.tsx +305 -367
  43. package/src/components/blocks/AccordionBlock.tsx +4 -4
  44. package/src/components/blocks/ArrayBlock.tsx +3 -3
  45. package/src/components/blocks/BlockEditModal.tsx +8 -8
  46. package/src/components/blocks/BlockWrapper.tsx +61 -0
  47. package/src/components/blocks/ButtonBlock.tsx +4 -4
  48. package/src/components/blocks/ChildBlocksTree.tsx +23 -25
  49. package/src/components/blocks/CodeBlock.tsx +15 -15
  50. package/src/components/blocks/ColumnsBlock.tsx +6 -44
  51. package/src/components/blocks/DividerBlock.tsx +3 -3
  52. package/src/components/blocks/FileBlock.tsx +4 -4
  53. package/src/components/blocks/HeadingBlock.tsx +6 -38
  54. package/src/components/blocks/HeroBlock.tsx +4 -4
  55. package/src/components/blocks/ImageBlock.tsx +4 -4
  56. package/src/components/blocks/LinkBlock.tsx +4 -4
  57. package/src/components/blocks/ListBlock.tsx +3 -3
  58. package/src/components/blocks/ParagraphBlock.tsx +12 -42
  59. package/src/components/blocks/RelationshipBlock.tsx +4 -4
  60. package/src/components/blocks/RichTextBlock.tsx +4 -4
  61. package/src/components/blocks/VStackBlock.tsx +5 -37
  62. package/src/components/blocks/VideoBlock.tsx +4 -4
  63. package/src/components/blocks/types.ts +11 -0
  64. package/src/components/fields/AccordionField.tsx +1 -1
  65. package/src/components/fields/ArrayField.tsx +2 -2
  66. package/src/components/fields/ArrayLayout.tsx +93 -0
  67. package/src/components/fields/BlocksField.tsx +122 -111
  68. package/src/components/fields/ButtonField.tsx +1 -1
  69. package/src/components/fields/CheckboxField.tsx +14 -15
  70. package/src/components/fields/ChildrenField.tsx +2 -2
  71. package/src/components/fields/CodeField.tsx +3 -3
  72. package/src/components/fields/ColumnsField.tsx +2 -2
  73. package/src/components/fields/DateField.tsx +13 -26
  74. package/src/components/fields/EditorClient.tsx +26 -28
  75. package/src/components/fields/FieldLayout.tsx +52 -0
  76. package/src/components/fields/GroupLayout.tsx +35 -0
  77. package/src/components/fields/JSONField.tsx +7 -7
  78. package/src/components/fields/LinkField.tsx +1 -1
  79. package/src/components/fields/MarkdownField.tsx +1 -1
  80. package/src/components/fields/NumberField.tsx +13 -26
  81. package/src/components/fields/PortableTextField.tsx +4 -4
  82. package/src/components/fields/PortableTextRenderer.tsx +1 -1
  83. package/src/components/fields/RelationshipBlockField.tsx +31 -23
  84. package/src/components/fields/RelationshipField.tsx +14 -14
  85. package/src/components/fields/SelectField.tsx +17 -26
  86. package/src/components/fields/TabsLayout.tsx +69 -0
  87. package/src/components/fields/TextField.tsx +85 -38
  88. package/src/components/fields/UploadField.tsx +71 -41
  89. package/src/components/fields/VideoField.tsx +1 -1
  90. package/src/components/fields/extensions/blockComponents.tsx +2 -2
  91. package/src/components/fields/extensions/blocksStore.ts +207 -193
  92. package/src/components/fields/types.ts +22 -0
  93. package/src/components/layout/Layout.tsx +1 -1
  94. package/src/components/ui/ActionMenu.tsx +63 -0
  95. package/src/components/ui/Badge.tsx +59 -5
  96. package/src/components/ui/BlockDrawer.tsx +4 -5
  97. package/src/components/ui/CommandPalette.tsx +58 -36
  98. package/src/components/ui/CommandPaletteWrapper.tsx +18 -17
  99. package/src/components/ui/Dropdown.tsx +18 -16
  100. package/src/components/ui/EmptyState.tsx +25 -0
  101. package/src/components/ui/GlobalModal.tsx +49 -0
  102. package/src/components/ui/IconButton.tsx +44 -0
  103. package/src/components/ui/Modal.tsx +19 -20
  104. package/src/components/ui/PageHeader.tsx +158 -0
  105. package/src/components/ui/Pagination.tsx +61 -0
  106. package/src/components/ui/PromptModal.tsx +1 -1
  107. package/src/components/ui/SearchInput.tsx +57 -0
  108. package/src/components/ui/SeoPreview.tsx +31 -0
  109. package/src/components/ui/SessionModal.tsx +0 -0
  110. package/src/components/ui/SlidePanel.tsx +2 -0
  111. package/src/components/ui/Toast.tsx +65 -122
  112. package/src/components/ui/Toaster.tsx +18 -0
  113. package/src/components/ui/icons.tsx +112 -0
  114. package/src/components/users/UserDetail.tsx +290 -0
  115. package/src/components/users/UserForm.tsx +242 -0
  116. package/src/components/users/UsersList.tsx +338 -0
  117. package/src/env.d.ts +13 -13
  118. package/src/fields/index.ts +2 -1
  119. package/src/global.d.ts +7 -0
  120. package/src/hooks/data.ts +2 -9
  121. package/src/hooks/useAsyncData.ts +36 -0
  122. package/src/hooks/useAutoFormState.ts +527 -0
  123. package/src/hooks/useSelection.ts +49 -0
  124. package/src/hooks/useSession.ts +0 -0
  125. package/src/index.ts +11 -1
  126. package/src/integration.ts +86 -11
  127. package/src/kyro-cms.d.ts +209 -0
  128. package/src/layouts/AdminLayout.astro +128 -11
  129. package/src/layouts/AuthLayout.astro +21 -5
  130. package/src/lib/api.ts +175 -55
  131. package/src/lib/autoform-store.ts +435 -0
  132. package/src/lib/config.ts +82 -34
  133. package/src/lib/createRegistry.ts +29 -0
  134. package/src/lib/default-kyro-config.ts +4 -0
  135. package/src/lib/globals.ts +50 -0
  136. package/src/lib/media-utils.ts +18 -0
  137. package/src/lib/object-utils.ts +77 -0
  138. package/src/lib/paths.ts +61 -0
  139. package/src/lib/stores/index.ts +370 -0
  140. package/src/lib/types.ts +43 -0
  141. package/src/lib/useResourceManager.ts +105 -0
  142. package/src/pages/403.astro +67 -0
  143. package/src/pages/[collection]/[id].astro +14 -180
  144. package/src/pages/[collection]/index.astro +11 -6
  145. package/src/pages/api-explorer.astro +173 -0
  146. package/src/pages/audit/index.astro +2 -0
  147. package/src/pages/auth/login.astro +122 -0
  148. package/src/pages/auth/register.astro +167 -0
  149. package/src/pages/graphql-explorer.astro +59 -0
  150. package/src/pages/{admin/graphql.astro → graphql.astro} +51 -17
  151. package/src/pages/index.astro +577 -0
  152. package/src/pages/index_ALT.astro +3 -0
  153. package/src/pages/keys.astro +11 -0
  154. package/src/pages/marketplace.astro +11 -0
  155. package/src/pages/media.astro +3 -0
  156. package/src/pages/plugins.astro +8 -0
  157. package/src/pages/preview/[collection]/[id].astro +188 -123
  158. package/src/pages/rest-playground.astro +62 -0
  159. package/src/pages/roles/index.astro +183 -76
  160. package/src/pages/sessions.astro +8 -0
  161. package/src/pages/settings/[slug].astro +92 -114
  162. package/src/pages/settings/index.astro +5 -3
  163. package/src/pages/users/[id].astro +25 -154
  164. package/src/pages/users/index.astro +19 -130
  165. package/src/pages/users/new.astro +9 -86
  166. package/src/pages/webhooks.astro +11 -0
  167. package/src/routes.ts +80 -0
  168. package/src/styles/main.css +119 -79
  169. package/src/theme/tokens.ts +1 -0
  170. package/src/vite-env.d.ts +14 -0
  171. package/src/collections/auth/index.ts +0 -155
  172. package/src/collections/portfolio/index.ts +0 -343
  173. package/src/components/ApiExplorer.tsx +0 -325
  174. package/src/components/EnhancedListView.tsx +0 -889
  175. package/src/components/GraphQLExplorer.tsx +0 -675
  176. package/src/components/Icons.tsx +0 -23
  177. package/src/components/StatusBadge.tsx +0 -76
  178. package/src/lib/MediaService.ts +0 -541
  179. package/src/lib/auth/sqlite-adapter.ts +0 -319
  180. package/src/lib/dataStore.ts +0 -226
  181. package/src/lib/db/adapter.ts +0 -54
  182. package/src/lib/db/drizzle-mysql-adapter.ts +0 -194
  183. package/src/lib/db/drizzle-mysql-auth-adapter.ts +0 -327
  184. package/src/lib/db/drizzle-postgres-adapter.ts +0 -202
  185. package/src/lib/db/drizzle-postgres-auth-adapter.ts +0 -304
  186. package/src/lib/db/drizzle-sqlite-adapter.ts +0 -227
  187. package/src/lib/db/drizzle-sqlite-auth-adapter.ts +0 -548
  188. package/src/lib/db/index.ts +0 -449
  189. package/src/lib/db/mongodb-adapter.ts +0 -207
  190. package/src/lib/db/mongodb-auth-adapter.ts +0 -305
  191. package/src/lib/db/schema/mysql-auth.ts +0 -113
  192. package/src/lib/db/schema/mysql-content.ts +0 -20
  193. package/src/lib/db/schema/postgres-auth.ts +0 -116
  194. package/src/lib/db/schema/postgres-content.ts +0 -35
  195. package/src/lib/db/schema/postgres-media.ts +0 -52
  196. package/src/lib/db/schema/postgres-settings.ts +0 -11
  197. package/src/lib/db/schema/sqlite-auth.ts +0 -112
  198. package/src/lib/db/schema/sqlite-content.ts +0 -20
  199. package/src/lib/db/version-adapter.ts +0 -248
  200. package/src/lib/graphql/index.ts +0 -1
  201. package/src/lib/graphql/schema.ts +0 -443
  202. package/src/lib/rate-limit.ts +0 -267
  203. package/src/lib/storage.ts +0 -374
  204. package/src/lib/store.ts +0 -85
  205. package/src/middleware.ts +0 -177
  206. package/src/pages/admin/api-explorer.astro +0 -98
  207. package/src/pages/admin/graphql-explorer.astro +0 -40
  208. package/src/pages/admin/index.astro +0 -286
  209. package/src/pages/admin/keys.astro +0 -8
  210. package/src/pages/admin/rest-playground.astro +0 -44
  211. package/src/pages/admin/webhooks.astro +0 -8
  212. package/src/pages/api/[collection]/[id]/publish.ts +0 -52
  213. package/src/pages/api/[collection]/[id]/unpublish.ts +0 -42
  214. package/src/pages/api/[collection]/[id]/versions.ts +0 -66
  215. package/src/pages/api/[collection]/[id].ts +0 -213
  216. package/src/pages/api/[collection]/index.ts +0 -209
  217. package/src/pages/api/auth/[id].ts +0 -121
  218. package/src/pages/api/auth/audit-logs.ts +0 -57
  219. package/src/pages/api/auth/login.ts +0 -211
  220. package/src/pages/api/auth/logout.ts +0 -66
  221. package/src/pages/api/auth/me.ts +0 -36
  222. package/src/pages/api/auth/refresh.ts +0 -119
  223. package/src/pages/api/auth/register.ts +0 -188
  224. package/src/pages/api/auth/users.ts +0 -97
  225. package/src/pages/api/collections.ts +0 -59
  226. package/src/pages/api/globals/[slug].ts +0 -42
  227. package/src/pages/api/graphql.ts +0 -90
  228. package/src/pages/api/health.ts +0 -426
  229. package/src/pages/api/keys/[id].ts +0 -26
  230. package/src/pages/api/keys/index.ts +0 -75
  231. package/src/pages/api/media/[id].ts +0 -309
  232. package/src/pages/api/media/folders.ts +0 -609
  233. package/src/pages/api/media/index.ts +0 -146
  234. package/src/pages/api/media/resize.ts +0 -267
  235. package/src/pages/api/search.ts +0 -82
  236. package/src/pages/api/slug-availability.ts +0 -70
  237. package/src/pages/api/storage-config.ts +0 -20
  238. package/src/pages/api/storage-status.ts +0 -206
  239. package/src/pages/api/upload.ts +0 -334
  240. package/src/pages/api/webhooks/index.ts +0 -71
  241. package/src/pages/login.astro +0 -82
  242. package/src/pages/register.astro +0 -102
@@ -1,9 +1,10 @@
1
- import React, { useState, useEffect, useCallback, useRef } from "react";
1
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { Spinner } from "./ui/Spinner";
4
- import { ConfirmModal } from "./ui/Modal";
5
4
  import { SlidePanel } from "./ui/SlidePanel";
6
5
  import { Badge } from "./ui/Badge";
6
+ import { Folder } from "./ui/icons";
7
+
7
8
  import {
8
9
  Trash2,
9
10
  Download,
@@ -19,7 +20,7 @@ import {
19
20
  Music,
20
21
  FileText,
21
22
  Archive,
22
- } from "lucide-react";
23
+ } from "./ui/icons";
23
24
  import { PromptModal } from "./ui/PromptModal";
24
25
  import ReactCrop, {
25
26
  type Crop,
@@ -36,6 +37,7 @@ import {
36
37
  withCacheBust,
37
38
  apiUpload,
38
39
  } from "../lib/api";
40
+ import { useAuthStore, useUIStore } from "../lib/stores";
39
41
 
40
42
  interface MediaItem {
41
43
  id: string;
@@ -98,1339 +100,1022 @@ function getFileType(mimeType: string): FilterType {
98
100
  if (
99
101
  mimeType.includes("zip") ||
100
102
  mimeType.includes("tar") ||
101
- mimeType.includes("rar")
103
+ mimeType.includes("archive")
102
104
  )
103
105
  return "archive";
104
106
  if (
105
107
  mimeType.includes("pdf") ||
106
- mimeType.includes("document") ||
108
+ mimeType.includes("doc") ||
107
109
  mimeType.includes("text")
108
110
  )
109
111
  return "document";
110
112
  return "other";
111
113
  }
112
114
 
113
- function getFileExtension(filename: string | undefined): string {
114
- if (!filename) return "FILE";
115
- const parts = filename.split(".");
116
- if (parts.length > 1) {
117
- return parts.pop()!.toUpperCase();
118
- }
119
- return "FILE";
120
- }
115
+ export function MediaGallery({
116
+ onSelect,
117
+ multiple = false,
118
+ }: {
119
+ onSelect?: (items: MediaItem[]) => void;
120
+ multiple?: boolean;
121
+ }) {
122
+ const { permissions } = useAuthStore();
123
+ const canUpload = permissions?.media?.create !== false;
124
+ const canDelete = permissions?.media?.delete !== false;
125
+ const canUpdate = permissions?.media?.update !== false;
121
126
 
122
- export function MediaGallery() {
123
127
  const [items, setItems] = useState<MediaItem[]>([]);
124
128
  const [loading, setLoading] = useState(true);
125
- const [uploading, setUploading] = useState(false);
126
- const [uploadProgress, setUploadProgress] = useState<number>(0);
129
+ const [folders, setFolders] = useState<string[]>([]);
130
+ const [currentFolder, setCurrentFolder] = useState<string>("");
131
+ const [search, setSearch] = useState("");
127
132
  const [filter, setFilter] = useState<FilterType>("all");
128
- const [searchQuery, setSearchQuery] = useState("");
129
- const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
130
- const [uploadQueue, setUploadQueue] = useState<
131
- Array<{
132
- name: string;
133
- idx: number;
134
- progress: number;
135
- controller?: AbortController;
136
- }>
137
- >([]);
138
- const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
139
- const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
140
- const [uploadDragOver, setUploadDragOver] = useState(false);
141
- const fileInputRef = useRef<HTMLInputElement>(null);
133
+ const [view, setView] = useState<"grid" | "list">("grid");
142
134
  const [panelItem, setPanelItem] = useState<MediaItem | null>(null);
143
- const [panelName, setPanelName] = useState<string>("");
144
- const [panelAlt, setPanelAlt] = useState<string>("");
145
- const [currentFolder, setCurrentFolder] = useState<string>("");
146
- const [availableFolders, setAvailableFolders] = useState<string[]>([]);
147
- const [uploadFolder, setUploadFolder] = useState<string>("");
135
+ const [showPreview, setShowPreview] = useState(false);
136
+ const [showCrop, setShowCrop] = useState(false);
137
+ const [uploading, setUploading] = useState(false);
138
+ const [uploadProgress, setUploadProgress] = useState<Record<string, number>>(
139
+ {},
140
+ );
148
141
  const [showNewFolderModal, setShowNewFolderModal] = useState(false);
149
- const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
150
- const [folderToDelete, setFolderToDelete] = useState<string>("");
151
-
142
+ const [storageConfigured, setStorageConfigured] = useState<boolean | null>(
143
+ null,
144
+ );
145
+ const [showStorageConfigModal, setShowStorageConfigModal] = useState(false);
152
146
  const [page, setPage] = useState(1);
153
- const [hasMore, setHasMore] = useState(true);
154
- const [sortBy, setSortBy] = useState("createdAt");
155
- const [sortDir, setSortDir] = useState("desc");
156
- const observerTarget = useRef<HTMLDivElement>(null);
147
+ const [total, setTotal] = useState(0);
148
+ const [totalPages, setTotalPages] = useState(1);
149
+ const limit = 40;
150
+
151
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
152
+ const { confirm, alert } = useUIStore();
153
+ const [isDragging, setIsDragging] = useState(false);
154
+
155
+ const fileInputRef = useRef<HTMLInputElement>(null);
157
156
  const [crop, setCrop] = useState<Crop>();
158
- const [showCrop, setShowCrop] = useState(false);
159
- const [copiedId, setCopiedId] = useState<string | null>(null);
160
- const [showStorageConfigModal, setShowStorageConfigModal] = useState(false);
161
- const [storageConfigured, setStorageConfigured] = useState(true);
162
- const [deleteConfirm, setDeleteConfirm] = useState<{
163
- open: boolean;
164
- count: number;
165
- ids?: string[];
166
- }>({ open: false, count: 0 });
167
157
  const imgRef = useRef<HTMLImageElement>(null);
168
158
 
169
- const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
170
- const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
171
- const defaultCrop = centerCrop(
172
- makeAspectCrop({ unit: "%", width: 90 }, width / height, width, height),
173
- width,
174
- height,
175
- );
176
- setCrop(defaultCrop);
177
- };
159
+ const loadMedia = useCallback(async () => {
160
+ setLoading(true);
161
+ try {
162
+ const params = new URLSearchParams({
163
+ page: page.toString(),
164
+ limit: limit.toString(),
165
+ });
166
+ if (currentFolder) params.append("folder", currentFolder);
167
+ if (search) params.append("search", search);
168
+ if (filter !== "all") params.append("type", filter);
178
169
 
179
- const openMetadataPanel = (item: MediaItem) => {
180
- setPanelItem(item);
181
- setPanelName(item.title ?? item.filename);
182
- setPanelAlt(item.alt ?? "");
183
- };
170
+ const result = await apiGet(withCacheBust(`/api/media?${params}`));
171
+ setItems((result.docs || []).map((doc: Record<string, unknown>) => ({
172
+ ...doc,
173
+ type: getFileType(doc.mimeType),
174
+ })));
175
+ setTotal(result.totalDocs || 0);
176
+ setTotalPages(result.totalPages || 1);
177
+ } catch (error) {
178
+ console.error("Failed to load media:", error);
179
+ } finally {
180
+ setLoading(false);
181
+ }
182
+ }, [page, currentFolder, search, filter]);
184
183
 
185
184
  const loadFolders = useCallback(async () => {
186
185
  try {
187
- const data = await apiGet("/api/media/folders");
188
- setAvailableFolders((data.folders || []).sort());
189
- } catch (e) {
190
- console.error("loadFolders error:", e);
186
+ const result = await apiGet(withCacheBust("/api/media/folders"));
187
+ setFolders(Array.isArray(result) ? result : result.folders || []);
188
+ } catch (error) {
189
+ console.error("Failed to load folders:", error);
191
190
  }
192
191
  }, []);
193
192
 
194
- const loadMedia = useCallback(
195
- async (reset = false) => {
196
- try {
197
- if (reset) setLoading(true);
198
- const currentPage = reset ? 1 : page;
199
- const typeParam = filter !== "all" ? `&type=${filter}` : "";
200
- const searchParam = searchQuery
201
- ? `&search=${encodeURIComponent(searchQuery)}`
202
- : "";
203
- const folderParam = currentFolder
204
- ? `&folder=${encodeURIComponent(currentFolder)}`
205
- : "";
206
-
207
- const result = await apiGet(
208
- withCacheBust(
209
- `/api/media?limit=30&page=${currentPage}&sortBy=${sortBy}&sortDir=${sortDir}${typeParam}${searchParam}${folderParam}`,
210
- ),
211
- );
212
-
213
- if (reset) {
214
- setItems(result.docs || []);
215
- } else {
216
- setItems((prev) => {
217
- const existingIds = new Set(prev.map((i) => i.id));
218
- const newItems = (result.docs || []).filter(
219
- (i: any) => !existingIds.has(i.id),
220
- );
221
- return [...prev, ...newItems];
222
- });
223
- }
224
- setHasMore(result.page < result.totalPages);
225
- if (reset) setPage(2);
226
- else setPage((p) => p + 1);
227
- } catch (error) {
228
- console.error("Failed to load media:", error);
229
- } finally {
230
- if (reset) setLoading(false);
231
- }
232
- },
233
- [page, filter, searchQuery, currentFolder, sortBy, sortDir],
234
- );
193
+ const checkStorage = useCallback(async () => {
194
+ try {
195
+ const res = await apiGet("/api/globals/storage-settings");
196
+ const isConfigured = !!res?.data?.provider;
197
+ setStorageConfigured(isConfigured);
198
+ } catch (e) {
199
+ setStorageConfigured(false);
200
+ }
201
+ }, []);
235
202
 
236
203
  useEffect(() => {
237
- // Check if storage settings are configured
238
- apiGet("/api/storage-status")
239
- .then((status) => {
240
- // If not configured, show modal to configure
241
- if (!status.configured) {
242
- setStorageConfigured(false);
243
- setShowStorageConfigModal(true);
244
- }
245
- })
246
- .catch(() => {
247
- setStorageConfigured(false);
248
- setShowStorageConfigModal(true);
249
- });
250
- }, []);
204
+ checkStorage();
205
+ }, [checkStorage]);
251
206
 
252
207
  useEffect(() => {
253
- loadMedia(true);
254
- }, [filter, searchQuery, currentFolder, sortBy, sortDir]);
208
+ loadMedia();
209
+ }, [loadMedia]);
255
210
 
256
211
  useEffect(() => {
257
212
  loadFolders();
258
213
  }, [loadFolders]);
259
214
 
260
215
  useEffect(() => {
261
- const observer = new IntersectionObserver(
262
- (entries) => {
263
- if (entries[0].isIntersecting && hasMore && !loading) {
264
- loadMedia();
265
- }
266
- },
267
- { rootMargin: "200px" },
268
- );
269
- if (observerTarget.current) observer.observe(observerTarget.current);
270
- return () => observer.disconnect();
271
- }, [hasMore, loadMedia, loading]);
216
+ const handlePaste = (e: ClipboardEvent) => {
217
+ const files = e.clipboardData?.files;
218
+ if (files && files.length > 0) {
219
+ handleUpload(files);
220
+ }
221
+ };
222
+ window.addEventListener("paste", handlePaste);
223
+ return () => window.removeEventListener("paste", handlePaste);
224
+ }, [currentFolder, storageConfigured]);
272
225
 
273
- const handleUpload = async (files: FileList) => {
274
- setUploading(true);
275
- setUploadProgress(0);
276
- setUploadQueue([]);
277
- const uploaded: MediaItem[] = [];
278
- const fileArray = Array.from(files);
226
+ const handleUpload = async (files: FileList | File[]) => {
227
+ if (!storageConfigured) {
228
+ setShowStorageConfigModal(true);
229
+ return;
230
+ }
279
231
 
280
- setUploadQueue(
281
- fileArray.map((f, idx) => ({
282
- name: f.name,
283
- idx,
284
- progress: 0,
285
- controller: undefined,
286
- })),
287
- );
232
+ setUploading(true);
233
+ const newProgress = { ...uploadProgress };
288
234
 
289
- for (let i = 0; i < fileArray.length; i++) {
290
- const file = fileArray[i];
235
+ for (let i = 0; i < files.length; i++) {
236
+ const file = files[i];
291
237
  try {
292
- const ac = new AbortController();
293
- setUploadQueue((prev) =>
294
- prev.map((q) => (q.idx === i ? { ...q, controller: ac } : q)),
295
- );
296
238
  const formData = new FormData();
297
239
  formData.append("file", file);
298
- if (uploadFolder) formData.append("folder", uploadFolder);
240
+ if (currentFolder) formData.append("folder", currentFolder);
299
241
 
300
- const result = await apiUpload("/api/upload", formData);
301
- const newItem: MediaItem = {
302
- ...result,
303
- type: getFileType(result.mimeType || file.type) as MediaItem["type"],
304
- };
305
- uploaded.push(newItem);
242
+ await apiUpload("/api/media/upload", formData, (progress: number) => {
243
+ setUploadProgress((prev) => ({
244
+ ...prev,
245
+ [file.name]: progress,
246
+ }));
247
+ });
306
248
  } catch (error) {
307
- console.error(`Error uploading ${file.name}:`, error);
249
+ console.error(`Upload failed for ${file.name}:`, error);
250
+ alert({ title: "Upload Failed", message: `Failed to upload ${file.name}` });
308
251
  }
309
- const pct = Math.round(((i + 1) / fileArray.length) * 100);
310
- setUploadProgress(pct);
311
- setUploadQueue((prev) =>
312
- prev.map((q) => (q.idx === i ? { ...q, progress: 100 } : q)),
313
- );
314
252
  }
315
253
 
316
- if (uploaded.length > 0) loadMedia(true);
317
254
  setUploading(false);
318
- setUploadProgress(0);
319
- setUploadQueue([]);
320
- };
321
-
322
- const handleDelete = async (id: string) => {
323
- setDeleteConfirm({ open: true, count: 1, ids: [id] });
324
- };
325
-
326
- const confirmDelete = async () => {
327
- const { ids } = deleteConfirm;
328
- if (!ids?.length) return;
329
- try {
330
- for (const id of ids) {
331
- await apiDelete(`/api/media/${id}`);
332
- }
333
- loadMedia(true);
334
- setSelectedItems(new Set());
335
- } catch {}
336
- setDeleteConfirm({ open: false, count: 0 });
337
- };
338
-
339
- const handleBulkDelete = async () => {
340
- if (selectedItems.size === 0) return;
341
- setDeleteConfirm({
342
- open: true,
343
- count: selectedItems.size,
344
- ids: Array.from(selectedItems),
345
- });
346
- };
347
-
348
- const toggleSelect = (id: string) => {
349
- setSelectedItems((prev) => {
350
- const next = new Set(prev);
351
- if (next.has(id)) next.delete(id);
352
- else next.add(id);
353
- return next;
354
- });
355
- };
356
-
357
- const selectAll = () => {
358
- if (selectedItems.size === items.length) setSelectedItems(new Set());
359
- else setSelectedItems(new Set(items.map((item) => item.id)));
255
+ setUploadProgress({});
256
+ loadMedia();
257
+ loadFolders();
360
258
  };
361
259
 
362
- const cancelUpload = (idx: number) => {
363
- setUploadQueue((prev) => {
364
- const target = prev.find((p) => p.idx === idx);
365
- if (target && (target as any).controller) {
260
+ const handleBulkDelete = () => {
261
+ confirm({
262
+ title: "Delete Media",
263
+ message: `Are you sure you want to delete ${selectedIds.size} item(s)? This cannot be undone.`,
264
+ variant: "danger",
265
+ onConfirm: async () => {
366
266
  try {
367
- (target as any).controller.abort();
368
- } catch {}
267
+ for (const id of Array.from(selectedIds)) {
268
+ await apiDelete(`/api/media/${id}`);
269
+ }
270
+ setSelectedIds(new Set());
271
+ loadMedia();
272
+ } catch (error) {
273
+ console.error("Bulk delete failed:", error);
274
+ alert({ title: "Error", message: "Failed to delete some items" });
275
+ }
369
276
  }
370
- return prev.filter((p) => p.idx !== idx);
371
277
  });
372
278
  };
373
279
 
374
- const handleDrop = (e: React.DragEvent) => {
375
- e.preventDefault();
376
- setUploadDragOver(false);
377
- if (e.dataTransfer.files.length > 0) {
378
- handleUpload(e.dataTransfer.files);
379
- }
380
- };
381
-
382
- const handleDragOver = (e: React.DragEvent) => {
383
- e.preventDefault();
384
- setUploadDragOver(true);
385
- };
386
-
387
- const handleDragLeave = () => {
388
- setUploadDragOver(false);
389
- };
390
-
391
- const handleMoveSelected = async (targetFolder: string) => {
392
- try {
393
- setLoading(true);
394
- const ids = Array.from(selectedItems);
395
- for (const id of ids) {
396
- await apiPatch(`/api/media/${id}`, { folder: targetFolder });
397
- }
398
- setSelectedItems(new Set());
399
- loadMedia(true);
400
- loadFolders();
401
- } catch (e) {
402
- console.error("Move error:", e);
403
- } finally {
404
- setLoading(false);
280
+ const handleSelectOne = (id: string, e: React.SyntheticEvent) => {
281
+ e.stopPropagation();
282
+ const newSet = new Set(selectedIds);
283
+ if (newSet.has(id)) {
284
+ newSet.delete(id);
285
+ } else {
286
+ if (!multiple) newSet.clear();
287
+ newSet.add(id);
405
288
  }
289
+ setSelectedIds(newSet);
406
290
  };
407
291
 
408
292
  const createFolder = async (name: string) => {
409
- if (!name) return;
410
293
  try {
411
- await apiPost("/api/media/folders", {
412
- name,
413
- parentPath: currentFolder || "",
414
- });
415
- const newPath = currentFolder ? `${currentFolder}/${name}` : name;
416
- setCurrentFolder(newPath);
417
- setUploadFolder(newPath);
294
+ await apiPost("/api/media/folders", { name });
418
295
  loadFolders();
419
- } catch (e) {
420
- console.error("Failed to create folder:", e);
296
+ setShowNewFolderModal(false);
297
+ } catch (error) {
298
+ console.error("Failed to create folder:", error);
421
299
  }
422
- setShowNewFolderModal(false);
423
300
  };
424
301
 
425
- const confirmDeleteFolder = (folder: string) => {
426
- setFolderToDelete(folder);
427
- setShowDeleteFolderModal(true);
302
+ const handleDeleteFolder = (folder: string) => {
303
+ confirm({
304
+ title: "Delete Folder",
305
+ message: `Are you sure you want to delete the folder "${folder}"? All media in this folder will be moved to the root.`,
306
+ variant: "danger",
307
+ confirmLabel: "Delete Folder",
308
+ onConfirm: async () => {
309
+ try {
310
+ await apiDelete(`/api/media/folders?path=${encodeURIComponent(folder)}`);
311
+ if (currentFolder === folder) setCurrentFolder("");
312
+ loadFolders();
313
+ loadMedia();
314
+ } catch (error) {
315
+ console.error("Failed to delete folder:", error);
316
+ alert({ title: "Error", message: "Failed to delete folder" });
317
+ }
318
+ }
319
+ });
428
320
  };
429
321
 
430
- const deleteFolder = async () => {
431
- if (!folderToDelete) return;
322
+ const updateMetadata = async (id: string, data: Partial<MediaItem>) => {
432
323
  try {
433
- const result = await apiDelete(
434
- `/api/media/folders?path=${encodeURIComponent(folderToDelete)}`,
435
- );
436
- console.log("[deleteFolder] Response:", result);
437
- // Clear items first, then reload
438
- setItems([]);
439
- if (currentFolder === folderToDelete) {
440
- setCurrentFolder("");
324
+ const result = await apiPatch(`/api/media/${id}`, data);
325
+ setItems((prev) => prev.map((item) => (item.id === id ? result.doc : item)));
326
+ if (panelItem?.id === id) {
327
+ setPanelItem(result.doc);
441
328
  }
442
- loadFolders();
443
- // Force fresh load by resetting page
444
- setPage(1);
445
- loadMedia(true);
446
- } catch (e) {
447
- console.error("Failed to delete folder:", e);
329
+ } catch (error) {
330
+ console.error("Failed to update metadata:", error);
448
331
  }
449
- setShowDeleteFolderModal(false);
450
- setFolderToDelete("");
451
332
  };
452
333
 
453
- const savePanelMetadata = async () => {
454
- if (!panelItem) return;
455
- try {
456
- await apiPatch(`/api/media/${panelItem.id}`, {
457
- title: panelName,
458
- alt: panelAlt,
459
- });
460
- setPanelItem(null);
461
- loadMedia(true);
462
- } catch (e) {
463
- console.error("Failed to save metadata:", e);
464
- }
334
+ const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
335
+ const { width, height } = e.currentTarget;
336
+ const initialCrop = centerCrop(
337
+ makeAspectCrop({ unit: "%", width: 90 }, 1, width, height),
338
+ width,
339
+ height,
340
+ );
341
+ setCrop(initialCrop);
465
342
  };
466
343
 
467
344
  const onCropComplete = async () => {
468
- if (!imgRef.current || !crop || !panelItem) return;
345
+ if (!crop || !imgRef.current || !panelItem) return;
469
346
 
470
- // Convert any percentage crops into standard pixels
471
- const pixelCrop = convertToPixelCrop(
472
- crop,
473
- imgRef.current.width,
474
- imgRef.current.height,
475
- );
347
+ setUploading(true);
348
+ try {
349
+ const pixelCrop = convertToPixelCrop(
350
+ crop,
351
+ imgRef.current.width,
352
+ imgRef.current.height,
353
+ );
476
354
 
477
- const canvas = document.createElement("canvas");
478
- const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
479
- const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
355
+ const canvas = document.createElement("canvas");
356
+ const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
357
+ const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
480
358
 
481
- // Set actual canvas geometry to the exact pixel width * scale
482
- canvas.width = pixelCrop.width * scaleX;
483
- canvas.height = pixelCrop.height * scaleY;
359
+ canvas.width = pixelCrop.width;
360
+ canvas.height = pixelCrop.height;
361
+ const ctx = canvas.getContext("2d");
484
362
 
485
- const ctx = canvas.getContext("2d");
486
- if (!ctx) return;
363
+ if (ctx) {
364
+ ctx.drawImage(
365
+ imgRef.current,
366
+ pixelCrop.x * scaleX,
367
+ pixelCrop.y * scaleY,
368
+ pixelCrop.width * scaleX,
369
+ pixelCrop.height * scaleY,
370
+ 0,
371
+ 0,
372
+ pixelCrop.width,
373
+ pixelCrop.height,
374
+ );
487
375
 
488
- ctx.drawImage(
489
- imgRef.current,
490
- pixelCrop.x * scaleX,
491
- pixelCrop.y * scaleY,
492
- pixelCrop.width * scaleX,
493
- pixelCrop.height * scaleY,
494
- 0,
495
- 0,
496
- pixelCrop.width * scaleX,
497
- pixelCrop.height * scaleY,
498
- );
376
+ const blob = await new Promise<Blob | null>((resolve) =>
377
+ canvas.toBlob(resolve, panelItem.mimeType),
378
+ );
499
379
 
500
- canvas.toBlob(
501
- async (blob) => {
502
- if (!blob) return;
503
- const formData = new FormData();
504
- const newFile = new File([blob], panelItem.filename, {
505
- type: "image/webp",
506
- });
507
- formData.append("file", newFile);
380
+ if (blob) {
381
+ const formData = new FormData();
382
+ formData.append("file", blob, `cropped-${panelItem.filename}`);
383
+ if (currentFolder) formData.append("folder", currentFolder);
508
384
 
509
- setUploading(true);
510
- try {
511
- const res = await apiUpload("/api/upload", formData);
512
- if (res.ok) {
513
- setShowCrop(false);
514
- setPanelItem(null);
515
- loadMedia(true);
516
- }
517
- } catch (e) {
518
- console.error("Crop save failed", e);
519
- } finally {
520
- setUploading(false);
385
+ await apiUpload("/api/media", formData);
386
+ loadMedia();
387
+ setShowCrop(false);
521
388
  }
522
- },
523
- "image/webp",
524
- 0.9,
525
- );
389
+ }
390
+ } catch (err) {
391
+ console.error("Crop failed:", err);
392
+ } finally {
393
+ setUploading(false);
394
+ }
526
395
  };
527
396
 
528
- const totalSize = items.reduce((acc, item) => acc + (item.fileSize || 0), 0);
529
-
530
- const FilterButton = ({
531
- type,
532
- label,
533
- }: {
534
- type: FilterType;
535
- label: string;
536
- }) => (
537
- <button
538
- type="button"
539
- onClick={() => setFilter(type)}
540
- className={`px-4 py-2 text-sm font-bold rounded-lg transition-colors ${
541
- filter === type
542
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)]"
543
- : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)]"
544
- }`}
545
- >
546
- {label}
547
- </button>
548
- );
397
+ const stats = useMemo(() => {
398
+ return items.reduce(
399
+ (acc, item) => {
400
+ acc.totalSize += item.fileSize || 0;
401
+ return acc;
402
+ },
403
+ { totalSize: 0 },
404
+ );
405
+ }, [items]);
549
406
 
550
407
  return (
551
- <div className="h-full flex flex-col bg-[var(--kyro-bg)] rounded-xl overflow-hidden relative">
552
- {/* Floating Bulk Actions Bar */}
553
- {selectedItems.size > 0 && (
554
- <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">
555
- <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">
556
- <div className="flex items-center gap-4 border-r border-white/10 pr-6 mr-6">
557
- <Badge className="bg-[var(--kyro-primary)] text-white">
558
- {selectedItems.size}
559
- </Badge>
560
- <span className="text-xs font-black uppercase tracking-widest opacity-60">
561
- Items Selected
408
+ <div
409
+ className={`flex flex-col h-full bg-[var(--kyro-bg)] transition-all duration-300 ${isDragging ? "ring-4 ring-[var(--kyro-sidebar-active)] ring-inset" : ""}`}
410
+ onDragOver={(e) => {
411
+ e.preventDefault();
412
+ setIsDragging(true);
413
+ }}
414
+ onDragLeave={() => setIsDragging(false)}
415
+ onDrop={(e) => {
416
+ e.preventDefault();
417
+ setIsDragging(false);
418
+ if (e.dataTransfer.files) handleUpload(e.dataTransfer.files);
419
+ }}
420
+ >
421
+ {/* Top Bar */}
422
+ <div className="flex flex-col lg:flex-row lg:items-center justify-between p-6 gap-6 border-b border-[var(--kyro-border)] surface-tile backdrop-blur-md sticky top-0 z-40 rounded-xl">
423
+ <div className="flex items-center gap-4">
424
+ <div>
425
+ <h2 className="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]">
426
+ Media Library
427
+ </h2>
428
+ <div className="flex items-center gap-3 mt-1">
429
+ <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50">
430
+ {total} Items · {formatFileSize(stats.totalSize)}
562
431
  </span>
563
432
  </div>
564
-
565
- <div className="flex items-center gap-4 px-4">
566
- <button
567
- type="button"
568
- onClick={handleBulkDelete}
569
- className="p-3 bg-[var(--kyro-danger-bg)] text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger)] hover:text-white rounded-full transition-all"
570
- title="Delete Selected"
571
- >
572
- <Trash2 className="w-5 h-5" />
573
- </button>
574
- <button
575
- type="button"
576
- className="p-3 bg-white/10 text-white hover:bg-white/20 rounded-full transition-all"
577
- title="Download Collection"
578
- >
579
- <Download className="w-5 h-5" />
580
- </button>
581
- </div>
582
-
583
- <button
584
- type="button"
585
- onClick={() => setSelectedItems(new Set())}
586
- className="p-2 hover:bg-white/10 rounded-full transition-all"
587
- >
588
- <X className="w-4 h-4" />
589
- </button>
590
433
  </div>
591
434
  </div>
592
- )}
593
435
 
594
- <div className="flex-shrink-0 p-8 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
595
- <div className="flex items-center justify-between mb-8">
596
- <div>
597
- <h1 className="text-2xl font-black text-[var(--kyro-text-primary)]">
598
- Media Library
599
- </h1>
600
- <p className="text-sm text-[var(--kyro-text-secondary)] mt-1">
601
- {items.length} files &middot; {formatFileSize(totalSize)} used
602
- </p>
436
+ <div className="flex items-center gap-3 flex-wrap lg:flex-nowrap">
437
+ <div className="relative group flex-1 min-w-[240px]">
438
+ <svg
439
+ className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-secondary)] opacity-40 group-focus-within:opacity-100 transition-opacity"
440
+ fill="none"
441
+ stroke="currentColor"
442
+ viewBox="0 0 24 24"
443
+ >
444
+ <path
445
+ strokeLinecap="round"
446
+ strokeLinejoin="round"
447
+ strokeWidth="2.5"
448
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
449
+ />
450
+ </svg>
451
+ <input
452
+ type="text"
453
+ placeholder="Search assets..."
454
+ value={search}
455
+ onChange={(e) => setSearch(e.target.value)}
456
+ className="w-full pl-11 pr-4 py-2.5 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] transition-all text-xs font-bold"
457
+ />
603
458
  </div>
604
- <div className="flex items-center gap-3">
459
+
460
+ <div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)]">
605
461
  <button
606
- type="button"
607
- onClick={() => setViewMode("grid")}
608
- 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)]"}`}
462
+ onClick={() => setView("grid")}
463
+ className={`p-2 rounded-lg transition-all ${view === "grid" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
609
464
  >
610
- <svg
611
- className="w-5 h-5"
612
- fill="none"
613
- stroke="currentColor"
614
- viewBox="0 0 24 24"
615
- >
616
- <path
617
- strokeLinecap="round"
618
- strokeLinejoin="round"
619
- strokeWidth={2}
620
- 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"
621
- />
622
- </svg>
465
+ <Grid className="w-4 h-4" />
623
466
  </button>
624
467
  <button
625
- type="button"
626
- onClick={() => setViewMode("list")}
627
- 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)]"}`}
468
+ onClick={() => setView("list")}
469
+ className={`p-2 rounded-lg transition-all ${view === "list" ? "bg-[var(--kyro-surface)] shadow-sm text-[var(--kyro-text-primary)]" : "text-[var(--kyro-text-secondary)] opacity-50 hover:opacity-100"}`}
628
470
  >
629
- <svg
630
- className="w-5 h-5"
631
- fill="none"
632
- stroke="currentColor"
633
- viewBox="0 0 24 24"
634
- >
635
- <path
636
- strokeLinecap="round"
637
- strokeLinejoin="round"
638
- strokeWidth={2}
639
- d="M4 6h16M4 12h16M4 18h16"
640
- />
641
- </svg>
471
+ <FileIcon className="w-4 h-4" />
642
472
  </button>
643
473
  </div>
474
+
475
+ {canUpload && (
476
+ <button
477
+ onClick={() => fileInputRef.current?.click()}
478
+ className="flex items-center gap-2 px-6 py-2.5 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg active:scale-95 transition-all"
479
+ >
480
+ <Maximize2 className="w-4 h-4" />
481
+ Upload
482
+ </button>
483
+ )}
644
484
  </div>
485
+ </div>
645
486
 
646
- <div className="flex flex-col sm:flex-row gap-4 mb-4 items-start sm:items-center w-full">
647
- <button
648
- type="button"
649
- onClick={() => fileInputRef.current?.click()}
650
- disabled={uploading}
651
- 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"
652
- >
653
- {uploading ? (
654
- <Spinner size="sm" />
655
- ) : (
656
- <svg
657
- className="w-4 h-4"
658
- fill="none"
659
- stroke="currentColor"
660
- viewBox="0 0 24 24"
661
- >
662
- <path
663
- strokeLinecap="round"
664
- strokeLinejoin="round"
665
- strokeWidth={2}
666
- d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
667
- />
668
- </svg>
669
- )}
670
- Upload
671
- </button>
672
- {availableFolders.length > 0 && (
673
- <div className="flex bg-[var(--kyro-surface-accent)] rounded-full p-1 gap-1 overflow-x-auto items-center">
674
- <button
675
- type="button"
676
- onClick={() => setCurrentFolder("")}
677
- 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)]"}`}
678
- >
679
- All
680
- </button>
681
- {availableFolders.map((folder) => (
682
- <div key={folder} className="flex items-center group">
683
- <button
684
- type="button"
685
- onClick={() => setCurrentFolder(folder)}
686
- 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)]"}`}
687
- >
688
- {folder}
689
- </button>
487
+ <div className="flex flex-1 min-h-0 overflow-hidden">
488
+ {/* Folders Sidebar */}
489
+ <div className="w-64 border-r border-[var(--kyro-border)] surface-tile mt-6 overflow-y-auto hidden md:block">
490
+ <div className="p-6 space-y-6">
491
+ <div>
492
+ <span className="text-[10px] font-bold tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40 block mb-4">
493
+ Quick Filters
494
+ </span>
495
+ <div className="space-y-1">
496
+ {(
497
+ [
498
+ "all",
499
+ "image",
500
+ "video",
501
+ "audio",
502
+ "document",
503
+ "archive",
504
+ ] as const
505
+ ).map((t) => (
690
506
  <button
691
- type="button"
692
- onClick={() => confirmDeleteFolder(folder)}
693
- 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"
694
- title="Delete folder"
507
+ key={t}
508
+ onClick={() => setFilter(t)}
509
+ className={`w-full flex items-center gap-3 px-4 py-2 rounded-xl text-[11px] font-bold capitalize transition-all ${filter === t ? "text-[var(--kyro-text-primary)] bg-[var(--kyro-surface-accent)]" : "text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]/50"}`}
695
510
  >
696
- <svg
697
- className="w-3 h-3"
698
- fill="none"
699
- stroke="currentColor"
700
- viewBox="0 0 24 24"
701
- >
702
- <path
703
- strokeLinecap="round"
704
- strokeLinejoin="round"
705
- strokeWidth={2}
706
- d="M6 18L18 6M6 6l12 12"
707
- />
708
- </svg>
511
+ <span
512
+ className={`w-1.5 h-1.5 rounded-full ${filter === t ? "bg-[var(--kyro-primary)]" : "bg-transparent border border-current opacity-30"}`}
513
+ />
514
+ {t}
709
515
  </button>
710
- </div>
711
- ))}
516
+ ))}
517
+ </div>
712
518
  </div>
713
- )}
714
- <button
715
- type="button"
716
- onClick={() => setShowNewFolderModal(true)}
717
- 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"
718
- >
719
- <FolderPlus className="w-4 h-4" />
720
- New Folder
721
- </button>
722
- <select
723
- value={uploadFolder}
724
- onChange={(e) => setUploadFolder(e.target.value)}
725
- className="px-2 py-1.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-full text-xs font-bold"
726
- >
727
- <option value="">Root</option>
728
- {availableFolders.map((folder) => (
729
- <option key={folder} value={folder}>
730
- {folder}
731
- </option>
732
- ))}
733
- </select>
734
- <select
735
- value={`${sortBy}-${sortDir}`}
736
- onChange={(e) => {
737
- const [sb, sd] = e.target.value.split("-");
738
- setSortBy(sb);
739
- setSortDir(sd);
740
- }}
741
- className="px-2 py-1.5 bg-[var(--kyro-bg)] border border-[var(--kyro-border)] rounded-full text-xs font-bold ml-auto"
742
- >
743
- <option value="createdAt-desc">Newest First</option>
744
- <option value="createdAt-asc">Oldest First</option>
745
- <option value="size-desc">Largest Size</option>
746
- <option value="size-asc">Smallest Size</option>
747
- <option value="name-asc">Name (A-Z)</option>
748
- <option value="name-desc">Name (Z-A)</option>
749
- </select>
750
- </div>
751
-
752
- <div className="relative mb-4">
753
- <svg
754
- className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--kyro-text-muted)]"
755
- fill="none"
756
- stroke="currentColor"
757
- viewBox="0 0 24 24"
758
- >
759
- <path
760
- strokeLinecap="round"
761
- strokeLinejoin="round"
762
- strokeWidth={2}
763
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
764
- />
765
- </svg>
766
- <input
767
- type="text"
768
- value={searchQuery}
769
- onChange={(e) => setSearchQuery(e.target.value)}
770
- placeholder="Search files..."
771
- 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)]"
772
- />
773
- </div>
774
-
775
- <div
776
- className={`flex-shrink-0 mx-6 mt-4 p-8 border-2 border-dashed rounded-xl text-center transition-colors ${
777
- uploadDragOver
778
- ? "border-[var(--kyro-border-active)] bg-[var(--kyro-surface-accent)] bg-opacity-20"
779
- : "border-[var(--kyro-border)] hover:border-[var(--kyro-border-active)] hover:bg-[var(--kyro-surface-accent)]"
780
- }`}
781
- onDrop={handleDrop}
782
- onDragOver={handleDragOver}
783
- onDragLeave={handleDragLeave}
784
- onClick={() => fileInputRef.current?.click()}
785
- >
786
- <svg
787
- className={`w-12 h-12 mx-auto mb-3 ${
788
- uploadDragOver
789
- ? "text-[var(--kyro-text-primary)]"
790
- : "text-[var(--kyro-text-muted)]"
791
- }`}
792
- fill="none"
793
- stroke="currentColor"
794
- viewBox="0 0 24 24"
795
- >
796
- <path
797
- strokeLinecap="round"
798
- strokeLinejoin="round"
799
- strokeWidth={1.5}
800
- 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"
801
- />
802
- </svg>
803
- <p className="text-[var(--kyro-text-secondary)] font-medium">
804
- {uploadDragOver
805
- ? "Drop files here"
806
- : "Drag and drop files here, or click Upload"}
807
- </p>
808
- <p className="text-xs text-[var(--kyro-text-muted)] mt-1">
809
- Supports images, videos, audio, documents, and archives
810
- </p>
811
- </div>
812
519
 
813
- {(uploading || uploadProgress > 0) && (
814
- <div className="w-full mt-4 flex items-center gap-4">
815
- <div className="flex-1 h-2 w-full bg-[var(--kyro-surface-accent)] rounded-full overflow-hidden">
816
- <div
817
- className="h-2 bg-[var(--kyro-sidebar-active)] transition-all duration-200"
818
- style={{ width: `${uploadProgress}%` }}
819
- />
820
- </div>
821
- <div className="text-xs text-[var(--kyro-text-secondary)] whitespace-nowrap">
822
- {uploadQueue.length > 0 ? (
823
- <span>
824
- {uploadQueue.filter((q) => q.progress === 100).length} /{" "}
825
- {uploadQueue.length}
520
+ <div className="pt-6 border-t border-[var(--kyro-border)]">
521
+ <div className="flex items-center justify-between mb-4">
522
+ <span className="text-[10px] font-bold tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40">
523
+ Folders
826
524
  </span>
827
- ) : (
828
- <span>{uploadProgress}%</span>
829
- )}
830
- </div>
831
- {uploadQueue.length > 1 && (
832
- <span className="text-xs text-[var(--kyro-text-muted)]">
833
- Uploading {uploadQueue.length} files...
834
- </span>
835
- )}
836
- </div>
837
- )}
838
-
839
- <div className="flex items-center gap-2 mt-4">
840
- <FilterButton type="all" label="All" />
841
- <FilterButton type="image" label="Images" />
842
- <FilterButton type="video" label="Videos" />
843
- <FilterButton type="audio" label="Audio" />
844
- <FilterButton type="document" label="Documents" />
845
- <FilterButton type="archive" label="Archives" />
846
-
847
- {selectedItems.size > 0 && (
848
- <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">
849
- <span className="text-[10px] font-black uppercase tracking-widest opacity-40 mr-2">
850
- {selectedItems.size} Selected
851
- </span>
852
- <button
853
- type="button"
854
- onClick={() => setSelectedItems(new Set())}
855
- className="text-[10px] font-black uppercase tracking-widest text-[var(--kyro-danger)] hover:underline"
856
- >
857
- Clear
858
- </button>
859
- </div>
860
- )}
861
- </div>
862
- </div>
863
-
864
- <div className="flex-1 overflow-y-auto pt-8 pb-32 px-2 bg-[var(--kyro-surface)]">
865
- {loading ? (
866
- <div className="flex items-center justify-center h-full">
867
- <Spinner size="lg" />
868
- </div>
869
- ) : items.length === 0 ? (
870
- <div className="flex flex-col items-center justify-center h-full text-[var(--kyro-text-muted)]">
871
- <svg
872
- className="w-16 h-16 mb-4 opacity-50"
873
- fill="none"
874
- stroke="currentColor"
875
- viewBox="0 0 24 24"
876
- >
877
- <path
878
- strokeLinecap="round"
879
- strokeLinejoin="round"
880
- strokeWidth={1.5}
881
- 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"
882
- />
883
- </svg>
884
- <p className="font-medium">No media files found</p>
885
- <p className="text-sm mt-1">Upload some files to get started</p>
886
- </div>
887
- ) : viewMode === "grid" ? (
888
- <div className="columns-2 sm:columns-3 md:columns-4 lg:columns-5 gap-2 space-y-2">
889
- {items.map((item, index) => (
890
- <div
891
- key={index}
892
- draggable
893
- onDragStart={(e) => {
894
- e.dataTransfer.setData("text/plain", item.id);
895
- e.dataTransfer.effectAllowed = "move";
896
- }}
897
- 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"}`}
898
- >
899
- <div
900
- className={`${item.type === "image" ? "aspect-auto" : "aspect-square"} flex items-center justify-center cursor-pointer`}
901
- onClick={() => {
902
- openMetadataPanel(item);
903
- }}
904
- >
905
- {item.type === "image" && item.thumbnailUrl ? (
906
- <img
907
- src={getAbsoluteUrl(item.thumbnailUrl)}
908
- alt={item.alt || item.title}
909
- className="w-full h-auto object-cover"
910
- loading="lazy"
911
- />
912
- ) : item.type === "image" ? (
913
- <img
914
- src={getAbsoluteUrl(item.url)}
915
- alt={item.alt || item.title}
916
- className="w-full h-auto object-cover"
917
- loading="lazy"
918
- />
919
- ) : (
920
- <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">
921
- {item.type === "video" ? (
922
- <Film className="w-12 h-12 mb-2" />
923
- ) : item.type === "audio" ? (
924
- <Music className="w-12 h-12 mb-2" />
925
- ) : item.type === "document" ? (
926
- <FileText className="w-12 h-12 mb-2" />
927
- ) : item.type === "archive" ? (
928
- <Archive className="w-12 h-12 mb-2" />
929
- ) : (
930
- <FileIcon className="w-12 h-12 mb-2" />
931
- )}
932
- <span className="font-semibold tracking-wider uppercase text-[10px]">
933
- {getFileExtension(item.filename || item.title)}
934
- </span>
935
- </div>
936
- )}
937
- </div>
938
525
  <button
939
- type="button"
940
- onClick={(e) => {
941
- e.stopPropagation();
942
- toggleSelect(item.id);
943
- }}
944
- 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"}`}
526
+ onClick={() => setShowNewFolderModal(true)}
527
+ className="p-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-colors text-[var(--kyro-text-primary)]"
945
528
  >
946
- {selectedItems.has(item.id) && (
947
- <svg
948
- className="w-4 h-4"
949
- fill="none"
950
- stroke="currentColor"
951
- viewBox="0 0 24 24"
952
- >
953
- <path
954
- strokeLinecap="round"
955
- strokeLinejoin="round"
956
- strokeWidth={2}
957
- d="M5 13l4 4L19 7"
958
- />
959
- </svg>
960
- )}
961
- </button>
962
- <button
963
- type="button"
964
- onClick={(e) => {
965
- e.stopPropagation();
966
- openMetadataPanel(item);
967
- }}
968
- 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"
969
- title="Edit metadata"
970
- >
971
- <svg
972
- className="w-4 h-4"
973
- fill="none"
974
- stroke="currentColor"
975
- viewBox="0 0 24 24"
976
- >
977
- <path
978
- strokeLinecap="round"
979
- strokeLinejoin="round"
980
- strokeWidth={2}
981
- d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
982
- />
983
- </svg>
529
+ <FolderPlus className="w-4 h-4" />
984
530
  </button>
531
+ </div>
532
+
533
+ <nav className="space-y-1">
985
534
  <button
986
- type="button"
987
- onClick={(e) => {
988
- e.stopPropagation();
989
- handleDelete(item.id);
990
- }}
991
- 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"
992
- title="Delete"
535
+ onClick={() => setCurrentFolder("")}
536
+ className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-xs font-bold transition-all ${currentFolder === "" ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-md" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"}`}
993
537
  >
994
- <svg
995
- className="w-4 h-4"
996
- fill="none"
997
- stroke="currentColor"
998
- viewBox="0 0 24 24"
999
- >
1000
- <path
1001
- strokeLinecap="round"
1002
- strokeLinejoin="round"
1003
- strokeWidth={2}
1004
- d="M6 18L18 6M6 6l12 12"
1005
- />
1006
- </svg>
538
+ <FolderInput className="w-4 h-4 opacity-70" />
539
+ All Assets
1007
540
  </button>
1008
- <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">
1009
- <p className="text-white text-xs font-medium truncate">
1010
- {item.title}
1011
- </p>
1012
- <p className="text-white/70 text-[10px]">
1013
- {formatFileSize(item.fileSize)}
1014
- </p>
1015
- </div>
1016
- </div>
1017
- ))}
541
+ {folders.map((folder) => (
542
+ <div key={folder} className="group relative">
543
+ <button
544
+ onClick={() => setCurrentFolder(folder)}
545
+ className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-xs font-bold transition-all ${currentFolder === folder ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-md" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"}`}
546
+ >
547
+ <div className="w-4 h-4 flex items-center justify-center opacity-70">
548
+ <Folder fill={currentFolder === folder ? "currentColor" : "none"} />
549
+ </div>
550
+ {folder}
551
+ </button>
552
+ <button
553
+ onClick={(e) => {
554
+ e.stopPropagation();
555
+ handleDeleteFolder(folder);
556
+ }}
557
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-red-500 opacity-0 group-hover:opacity-100 transition-all hover:bg-red-50 rounded-lg"
558
+ >
559
+ <Trash2 className="w-3.5 h-3.5" />
560
+ </button>
561
+ </div>
562
+ ))}
563
+ </nav>
564
+ </div>
1018
565
  </div>
1019
- ) : (
1020
- <div className="space-y-2">
1021
- <div className="flex items-center gap-4 px-4 py-2 text-xs font-bold text-[var(--kyro-text-muted)] uppercase">
1022
- <button
1023
- type="button"
1024
- onClick={selectAll}
1025
- className="w-6 h-6 rounded border border-[var(--kyro-border)] flex items-center justify-center hover:bg-[var(--kyro-surface-accent)]"
1026
- >
1027
- {selectedItems.size === items.length && items.length > 0 && (
1028
- <svg
1029
- className="w-4 h-4"
1030
- fill="none"
1031
- stroke="currentColor"
1032
- viewBox="0 0 24 24"
566
+ </div>
567
+
568
+ {/* Main Content Area */}
569
+ <div className="flex-1 flex flex-col min-h-0 bg-[var(--kyro-bg)]">
570
+ <div className="flex-1 overflow-y-auto py-8 px-4 custom-scrollbar">
571
+ {loading ? (
572
+ <div className="flex items-center justify-center h-64">
573
+ <Spinner />
574
+ </div>
575
+ ) : items.length === 0 ? (
576
+ <div className="flex flex-col items-center justify-center py-32 text-center">
577
+ <div className="w-24 h-24 rounded-[2rem] bg-[var(--kyro-surface-accent)] flex items-center justify-center mb-8 rotate-12 group-hover:rotate-0 transition-transform duration-500">
578
+ <Grid className="w-10 h-10 text-[var(--kyro-text-muted)] opacity-30" />
579
+ </div>
580
+ <h3 className="text-xl font-bold text-[var(--kyro-text-primary)] tracking-tight">
581
+ No assets found
582
+ </h3>
583
+ <p className="text-[var(--kyro-text-secondary)] mt-2 max-w-xs mx-auto text-sm font-medium leading-relaxed">
584
+ Upload your first file or create a folder to organize your
585
+ media assets.
586
+ </p>
587
+ {canUpload && (
588
+ <button
589
+ onClick={() => fileInputRef.current?.click()}
590
+ className="mt-8 px-8 py-3 bg-[var(--kyro-text-primary)] text-[var(--kyro-bg)] rounded-2xl font-bold text-xs hover:scale-105 transition-all shadow-xl"
1033
591
  >
1034
- <path
1035
- strokeLinecap="round"
1036
- strokeLinejoin="round"
1037
- strokeWidth={2}
1038
- d="M5 13l4 4L19 7"
1039
- />
1040
- </svg>
592
+ Start Uploading
593
+ </button>
1041
594
  )}
1042
- </button>
1043
- <span className="flex-1">Name</span>
1044
- <span className="w-24 text-right">Type</span>
1045
- <span className="w-24 text-right">Size</span>
1046
- <span className="w-32">Date</span>
1047
- <span className="w-10"></span>
1048
- </div>
1049
- {items.map((item) => (
1050
- <div
1051
- key={item.id}
1052
- draggable
1053
- onDragStart={(e) => {
1054
- e.dataTransfer.setData("text/plain", item.id);
1055
- e.dataTransfer.effectAllowed = "move";
1056
- }}
1057
- 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)]"}`}
1058
- >
1059
- <button
1060
- type="button"
1061
- onClick={() => toggleSelect(item.id)}
1062
- 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)]"}`}
1063
- >
1064
- {selectedItems.has(item.id) && (
1065
- <svg
1066
- className="w-4 h-4"
1067
- fill="none"
1068
- stroke="currentColor"
1069
- viewBox="0 0 24 24"
1070
- >
1071
- <path
1072
- strokeLinecap="round"
1073
- strokeLinejoin="round"
1074
- strokeWidth={2}
1075
- d="M5 13l4 4L19 7"
1076
- />
1077
- </svg>
1078
- )}
1079
- </button>
1080
- <div className="flex-1 flex items-center gap-3 min-w-0">
595
+ </div>
596
+ ) : view === "grid" ? (
597
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-1">
598
+ {items.map((item, index) => (
1081
599
  <div
1082
- className="w-10 h-10 rounded-lg bg-[var(--kyro-surface)] flex items-center justify-center overflow-hidden flex-shrink-0 cursor-pointer"
1083
- onClick={() => openMetadataPanel(item)}
600
+ key={item.id}
601
+ className={`group relative aspect-square rounded-lg overflow-hidden bg-[var(--kyro-surface-accent)] border-2 transition-all duration-300 cursor-pointer ${selectedIds.has(item.id) ? "border-[var(--kyro-primary)]" : "border-transparent hover:border-[var(--kyro-border-strong)] hover:shadow-2xl hover:-translate-y-1"}`}
602
+ onClick={() => setPanelItem(item)}
603
+ onContextMenu={(e) => {
604
+ e.preventDefault();
605
+ handleSelectOne(item.id, e);
606
+ }}
1084
607
  >
1085
- {item.type === "image" &&
1086
- (item.thumbnailUrl || item.url) ? (
608
+ {item.type === "image" ? (
1087
609
  <img
1088
- src={getAbsoluteUrl(item.thumbnailUrl || item.url)}
1089
- alt=""
1090
- className="w-full h-full object-cover"
610
+ src={item.url}
611
+ alt={item.title}
612
+ className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
613
+ loading="lazy"
1091
614
  />
1092
- ) : item.type === "video" ? (
1093
- <Film className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1094
- ) : item.type === "audio" ? (
1095
- <Music className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1096
- ) : item.type === "document" ? (
1097
- <FileText className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1098
- ) : item.type === "archive" ? (
1099
- <Archive className="w-5 h-5 text-[var(--kyro-text-muted)]" />
1100
615
  ) : (
1101
- <FileIcon className="w-5 h-5 text-[var(--kyro-text-muted)]" />
616
+ <div className="w-full h-full flex flex-col items-center justify-center p-6 gap-4">
617
+ <div className="p-4 rounded-2xl bg-[var(--kyro-surface)] shadow-inner text-[var(--kyro-text-secondary)] group-hover:scale-110 transition-transform duration-500">
618
+ {item.type === "video" ? (
619
+ <Film className="w-8 h-8" />
620
+ ) : item.type === "audio" ? (
621
+ <Music className="w-8 h-8" />
622
+ ) : item.type === "document" ? (
623
+ <FileText className="w-8 h-8" />
624
+ ) : item.type === "archive" ? (
625
+ <Archive className="w-8 h-8" />
626
+ ) : (
627
+ <FileIcon className="w-8 h-8" />
628
+ )}
629
+ </div>
630
+ <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 text-center px-4 line-clamp-2">
631
+ {item.title}
632
+ </span>
633
+ </div>
634
+ )}
635
+
636
+ <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
637
+ <div className="flex items-center justify-between">
638
+ <span className="text-white font-bold text-[10px] truncate max-w-[120px]">
639
+ {item.filename}
640
+ </span>
641
+ <div className="flex gap-1">
642
+ <button
643
+ onClick={(e) => handleSelectOne(item.id, e)}
644
+ className={`p-1.5 rounded-lg transition-all ${selectedIds.has(item.id) ? "bg-[var(--kyro-primary)] text-white" : "bg-white/10 text-white hover:bg-white/20"}`}
645
+ >
646
+ <svg
647
+ className="w-3 h-3"
648
+ fill="none"
649
+ stroke="currentColor"
650
+ viewBox="0 0 24 24"
651
+ >
652
+ <path
653
+ strokeLinecap="round"
654
+ strokeLinejoin="round"
655
+ strokeWidth="3"
656
+ d="M5 13l4 4L19 7"
657
+ />
658
+ </svg>
659
+ </button>
660
+ </div>
661
+ </div>
662
+ </div>
663
+
664
+ {selectedIds.has(item.id) && (
665
+ <div className="absolute top-3 left-3 w-6 h-6 rounded-lg bg-[var(--kyro-primary)] text-white flex items-center justify-center shadow-lg border-2 border-white/20 animate-in zoom-in duration-300">
666
+ <svg
667
+ className="w-3 h-3"
668
+ fill="none"
669
+ stroke="currentColor"
670
+ viewBox="0 0 24 24"
671
+ >
672
+ <path
673
+ strokeLinecap="round"
674
+ strokeLinejoin="round"
675
+ strokeWidth="3"
676
+ d="M5 13l4 4L19 7"
677
+ />
678
+ </svg>
679
+ </div>
1102
680
  )}
1103
681
  </div>
1104
- <div
1105
- className="min-w-0 flex-1 cursor-pointer"
1106
- onClick={() => openMetadataPanel(item)}
1107
- >
1108
- <p className="font-medium text-[var(--kyro-text-primary)] truncate">
1109
- {item.title}
1110
- </p>
1111
- <p className="text-xs text-[var(--kyro-text-muted)] truncate">
1112
- {item.originalName || item.filename}
1113
- </p>
1114
- </div>
1115
- </div>
1116
- <span className="w-24 text-right text-sm text-[var(--kyro-text-secondary)] capitalize">
1117
- {item.type}
1118
- </span>
1119
- <span className="w-24 text-right text-sm text-[var(--kyro-text-secondary)]">
1120
- {formatFileSize(item.fileSize)}
1121
- </span>
1122
- <span className="w-32 text-sm text-[var(--kyro-text-muted)]">
1123
- {formatDate(item.createdAt)}
1124
- </span>
682
+ ))}
683
+ </div>
684
+ ) : (
685
+ <div className="surface-tile overflow-hidden animate-in fade-in duration-500">
686
+ <table className="w-full text-left">
687
+ <thead>
688
+ <tr className="text-[10px] font-bold tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-40 border-b border-[var(--kyro-border)]">
689
+ <th className="px-6 py-4 w-12">
690
+ <input
691
+ type="checkbox"
692
+ className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
693
+ checked={selectedIds.size === items.length}
694
+ onChange={(e) => {
695
+ if (e.target.checked)
696
+ setSelectedIds(new Set(items.map((i) => i.id)));
697
+ else setSelectedIds(new Set());
698
+ }}
699
+ />
700
+ </th>
701
+ <th className="px-6 py-4">Asset</th>
702
+ <th className="px-6 py-4">Type</th>
703
+ <th className="px-6 py-4">Size</th>
704
+ <th className="px-6 py-4">Date</th>
705
+ <th className="px-6 py-4 text-right">Actions</th>
706
+ </tr>
707
+ </thead>
708
+ <tbody className="divide-y divide-[var(--kyro-border)]">
709
+ {items.map((item) => (
710
+ <tr
711
+ key={item.id}
712
+ className={`group hover:bg-[var(--kyro-surface-accent)] transition-colors cursor-pointer ${selectedIds.has(item.id) ? "bg-[var(--kyro-surface-accent)]" : ""}`}
713
+ onClick={() => setPanelItem(item)}
714
+ >
715
+ <td
716
+ className="px-6 py-4"
717
+ onClick={(e) => e.stopPropagation()}
718
+ >
719
+ <input
720
+ type="checkbox"
721
+ checked={selectedIds.has(item.id)}
722
+ onChange={(e) => handleSelectOne(item.id, e)}
723
+ className="w-4 h-4 rounded border-[var(--kyro-border)] text-[var(--kyro-primary)] focus:ring-[var(--kyro-primary)]"
724
+ />
725
+ </td>
726
+ <td className="px-6 py-4">
727
+ <div className="flex items-center gap-4">
728
+ <div className="w-12 h-12 rounded-xl bg-[var(--kyro-bg)] overflow-hidden border border-[var(--kyro-border)] flex-shrink-0 flex items-center justify-center">
729
+ {item.type === "image" ? (
730
+ <img
731
+ src={item.url}
732
+ alt=""
733
+ className="w-full h-full object-cover"
734
+ />
735
+ ) : (
736
+ <FileIcon className="w-5 h-5 text-[var(--kyro-text-secondary)] opacity-50" />
737
+ )}
738
+ </div>
739
+ <div className="flex flex-col min-w-0">
740
+ <span className="font-bold text-xs text-[var(--kyro-text-primary)] truncate max-w-[200px]">
741
+ {item.title || item.filename}
742
+ </span>
743
+ <span className="text-[10px] text-[var(--kyro-text-secondary)] opacity-50 truncate">
744
+ {item.filename}
745
+ </span>
746
+ </div>
747
+ </div>
748
+ </td>
749
+ <td className="px-6 py-4">
750
+ <Badge variant="outline" className="text-[9px]">
751
+ {item.mimeType}
752
+ </Badge>
753
+ </td>
754
+ <td className="px-6 py-4 text-[11px] font-bold text-[var(--kyro-text-secondary)]">
755
+ {formatFileSize(item.fileSize)}
756
+ </td>
757
+ <td className="px-6 py-4 text-[11px] font-bold text-[var(--kyro-text-secondary)]">
758
+ {formatDate(item.createdAt)}
759
+ </td>
760
+ <td className="px-6 py-4 text-right">
761
+ <button
762
+ onClick={(e) => {
763
+ e.stopPropagation();
764
+ handleSelectOne(item.id, e);
765
+ }}
766
+ className={`p-2 rounded-lg transition-all ${selectedIds.has(item.id) ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] opacity-0 group-hover:opacity-100"}`}
767
+ >
768
+ <Grid className="w-4 h-4" />
769
+ </button>
770
+ </td>
771
+ </tr>
772
+ ))}
773
+ </tbody>
774
+ </table>
775
+ </div>
776
+ )}
777
+ </div>
778
+
779
+ {/* Pagination */}
780
+ {totalPages > 1 && (
781
+ <div className="p-6 border-t border-[var(--kyro-border)] bg-[var(--kyro-surface)]/50 backdrop-blur-md flex items-center justify-between">
782
+ <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50">
783
+ Page {page} of {totalPages}
784
+ </span>
785
+ <div className="flex gap-2">
1125
786
  <button
1126
- type="button"
1127
- onClick={(e) => {
1128
- e.stopPropagation();
1129
- openMetadataPanel(item);
1130
- }}
1131
- 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"
1132
- title="Edit details"
787
+ disabled={page === 1}
788
+ onClick={() => setPage(page - 1)}
789
+ className="px-4 py-2 border border-[var(--kyro-border)] rounded-xl text-xs font-bold text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] disabled:opacity-30 transition-all"
1133
790
  >
1134
- <svg
1135
- className="w-4 h-4"
1136
- fill="none"
1137
- stroke="currentColor"
1138
- viewBox="0 0 24 24"
1139
- >
1140
- <path
1141
- strokeLinecap="round"
1142
- strokeLinejoin="round"
1143
- strokeWidth={1.5}
1144
- 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"
1145
- />
1146
- </svg>
791
+ Previous
1147
792
  </button>
1148
793
  <button
1149
- type="button"
1150
- onClick={() => handleDelete(item.id)}
1151
- 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"
794
+ disabled={page === totalPages}
795
+ onClick={() => setPage(page + 1)}
796
+ className="px-6 py-2 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl text-xs font-bold shadow-lg hover:opacity-90 disabled:opacity-30 transition-all"
1152
797
  >
1153
- <svg
1154
- className="w-5 h-5"
1155
- fill="none"
1156
- stroke="currentColor"
1157
- viewBox="0 0 24 24"
1158
- >
1159
- <path
1160
- strokeLinecap="round"
1161
- strokeLinejoin="round"
1162
- strokeWidth={1.5}
1163
- 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"
1164
- />
1165
- </svg>
798
+ Next
1166
799
  </button>
1167
800
  </div>
1168
- ))}
1169
- </div>
1170
- )}
801
+ </div>
802
+ )}
803
+ </div>
804
+ </div>
1171
805
 
1172
- {/* Infinite Scroll Observer Target */}
1173
- {hasMore && !loading && (
1174
- <div
1175
- ref={observerTarget}
1176
- className="h-20 w-full flex items-center justify-center mt-4"
1177
- >
1178
- <Spinner size="sm" />
806
+ {/* Upload Banner */}
807
+ {uploading && (
808
+ <div className="fixed bottom-12 left-1/2 -translate-x-1/2 z-[60] w-full max-w-lg">
809
+ <div className="bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-[2rem] shadow-2xl p-6 ring-1 ring-white/10 animate-in slide-in-from-bottom-12 duration-700">
810
+ <div className="flex items-center justify-between mb-4">
811
+ <div className="flex items-center gap-3">
812
+ <div className="w-8 h-8 rounded-full bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] flex items-center justify-center animate-pulse">
813
+ <Spinner />
814
+ </div>
815
+ <span className="text-sm font-bold tracking-tight text-[var(--kyro-text-primary)]">
816
+ Uploading Files
817
+ </span>
818
+ </div>
819
+ <span className="text-[10px] font-bold tracking-[0.2em] text-[var(--kyro-text-secondary)] opacity-50">
820
+ {Object.keys(uploadProgress).length} Total
821
+ </span>
822
+ </div>
823
+ <div className="space-y-4 max-h-[160px] overflow-y-auto pr-2 custom-scrollbar">
824
+ {Object.entries(uploadProgress).map(([name, progress]) => (
825
+ <div key={name} className="space-y-2">
826
+ <div className="flex justify-between text-[10px] font-bold">
827
+ <span className="text-[var(--kyro-text-primary)] truncate max-w-[200px]">
828
+ {name}
829
+ </span>
830
+ <span className="text-[var(--kyro-primary)]">
831
+ {progress}%
832
+ </span>
833
+ </div>
834
+ <div className="h-1.5 w-full bg-[var(--kyro-surface-accent)] rounded-full overflow-hidden">
835
+ <div
836
+ className="h-full bg-[var(--kyro-primary)] transition-all duration-300 rounded-full"
837
+ style={{ width: `${progress}%` }}
838
+ />
839
+ </div>
840
+ </div>
841
+ ))}
842
+ </div>
1179
843
  </div>
1180
- )}
1181
- </div>
844
+ </div>
845
+ )}
1182
846
 
1183
- {lightboxItem &&
1184
- createPortal(
1185
- <div
1186
- className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center p-8 animate-in fade-in duration-200"
1187
- onClick={() => setLightboxItem(null)}
1188
- >
1189
- <button
1190
- type="button"
1191
- onClick={() => setLightboxItem(null)}
1192
- 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"
1193
- >
1194
- <svg
1195
- className="w-6 h-6"
1196
- fill="none"
1197
- stroke="currentColor"
1198
- viewBox="0 0 24 24"
1199
- >
1200
- <path
1201
- strokeLinecap="round"
1202
- strokeLinejoin="round"
1203
- strokeWidth={2}
1204
- d="M6 18L18 6M6 6l12 12"
1205
- />
1206
- </svg>
1207
- </button>
1208
- <div className="absolute top-4 left-4 text-white">
1209
- <p className="font-bold text-lg">{lightboxItem.title}</p>
1210
- <p className="text-sm text-white/60">{lightboxItem.filename}</p>
1211
- <p className="text-xs text-white/40 mt-1">
1212
- {formatFileSize(lightboxItem.fileSize)}
1213
- </p>
847
+ {/* Selection Footer */}
848
+ {selectedIds.size > 0 && (
849
+ <div className="fixed bottom-12 left-1/2 -translate-x-1/2 z-[60] bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-full shadow-2xl px-2 py-2 flex items-center gap-12 animate-in slide-in-from-bottom-12 duration-700 ring-1 ring-white/10 backdrop-blur-xl">
850
+ <div className="flex items-center gap-5 border-r border-[var(--kyro-border)] pr-12">
851
+ <div className="w-12 h-12 rounded-full bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] flex items-center justify-center text-lg font-bold shadow-inner">
852
+ {selectedIds.size}
1214
853
  </div>
1215
- <img
1216
- src={getAbsoluteUrl(lightboxItem.url)}
1217
- alt={lightboxItem.alt || lightboxItem.title}
1218
- className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
1219
- onClick={(e) => e.stopPropagation()}
1220
- />
1221
- <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4">
1222
- <a
1223
- href={getAbsoluteUrl(lightboxItem.url)}
1224
- target="_blank"
1225
- rel="noopener noreferrer"
1226
- className="px-4 py-2 bg-white/10 text-white rounded-lg font-bold text-sm hover:bg-white/20 transition-colors"
1227
- onClick={(e) => e.stopPropagation()}
854
+ <div>
855
+ <p className="text-[11px] font-bold tracking-[0.2em] text-[var(--kyro-text-primary)]">
856
+ Selected
857
+ </p>
858
+ <button
859
+ onClick={() => setSelectedIds(new Set())}
860
+ className="text-[10px] font-bold text-[var(--kyro-primary)] hover:underline opacity-80"
1228
861
  >
1229
- Open in new tab
1230
- </a>
862
+ Clear all
863
+ </button>
864
+ </div>
865
+ </div>
866
+ <div className="flex items-center gap-4">
867
+ {onSelect && (
1231
868
  <button
1232
- type="button"
1233
- onClick={(e) => {
1234
- e.stopPropagation();
1235
- navigator.clipboard.writeText(
1236
- getAbsoluteUrl(lightboxItem.url),
1237
- );
1238
- setCopiedId("lightbox");
1239
- setTimeout(() => setCopiedId(null), 2000);
869
+ onClick={() => {
870
+ const selectedItems = items.filter((i) => selectedIds.has(i.id));
871
+ onSelect(selectedItems);
1240
872
  }}
1241
- className="px-4 py-2 bg-white/10 text-white rounded-lg font-bold text-sm hover:bg-white/20 transition-colors"
873
+ className="px-8 py-3 bg-[var(--kyro-text-primary)] text-[var(--kyro-bg)] rounded-full font-bold text-xs hover:scale-105 transition-all shadow-xl"
1242
874
  >
1243
- {copiedId === "lightbox" ? "Copied!" : "Copy URL"}
875
+ Confirm Selection
1244
876
  </button>
1245
- </div>
1246
- </div>,
1247
- document.body,
1248
- )}
1249
-
1250
- {panelItem && (
1251
- <div className="fixed inset-0 z-50 flex justify-end">
1252
- <div
1253
- className="absolute inset-0 bg-black/30"
1254
- onClick={() => setPanelItem(null)}
1255
- />
1256
- <div className="relative w-100 h-full bg-[var(--kyro-surface)] border-l border-[var(--kyro-border)] shadow-xl p-6 flex flex-col">
1257
- <div className="flex items-center justify-between mb-6">
1258
- <h2 className="text-lg font-bold text-[var(--kyro-text-primary)]">
1259
- Media Details
1260
- </h2>
877
+ )}
878
+ {canDelete && (
1261
879
  <button
1262
- type="button"
1263
- onClick={() => setPanelItem(null)}
1264
- className="p-2 rounded-lg hover:bg-[var(--kyro-surface-accent)]"
880
+ onClick={handleBulkDelete}
881
+ className="p-3 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white rounded-full transition-all active:scale-90"
1265
882
  >
1266
- <svg
1267
- className="w-5 h-5 text-[var(--kyro-text-secondary)]"
1268
- fill="none"
1269
- stroke="currentColor"
1270
- viewBox="0 0 24 24"
1271
- >
1272
- <path
1273
- strokeLinecap="round"
1274
- strokeLinejoin="round"
1275
- strokeWidth={2}
1276
- d="M6 18L18 6M6 6l12 12"
1277
- />
1278
- </svg>
883
+ <Trash2 className="w-5 h-5" />
1279
884
  </button>
1280
- </div>
1281
- <div className="aspect-square bg-[var(--kyro-surface-accent)] rounded-lg mb-6 flex items-center justify-center overflow-hidden">
885
+ )}
886
+ </div>
887
+ </div>
888
+ )}
889
+
890
+ {/* Asset Panel */}
891
+ <SlidePanel
892
+ open={!!panelItem}
893
+ onClose={() => setPanelItem(null)}
894
+ title="Asset Details"
895
+ subtitle={panelItem?.filename}
896
+ >
897
+ {panelItem && (
898
+ <div className="flex flex-col h-full">
899
+ <div className="aspect-video w-full rounded-2xl bg-[var(--kyro-bg)] border border-[var(--kyro-border)] overflow-hidden relative group">
1282
900
  {panelItem.type === "image" ? (
1283
901
  <img
1284
- src={getAbsoluteUrl(panelItem.thumbnailUrl || panelItem.url)}
902
+ src={getAbsoluteUrl(panelItem.url)}
1285
903
  alt=""
1286
- className="w-full h-full object-contain"
904
+ className="w-full h-full object-contain p-4"
905
+ />
906
+ ) : panelItem.type === "video" ? (
907
+ <video
908
+ src={getAbsoluteUrl(panelItem.url)}
909
+ controls
910
+ className="w-full h-full"
1287
911
  />
1288
912
  ) : (
1289
- <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)]">
1290
- {panelItem.type === "video" ? (
1291
- <Film className="w-24 h-24 mb-4 opacity-50" />
1292
- ) : panelItem.type === "audio" ? (
1293
- <Music className="w-24 h-24 mb-4 opacity-50" />
1294
- ) : panelItem.type === "document" ? (
1295
- <FileText className="w-24 h-24 mb-4 opacity-50" />
1296
- ) : panelItem.type === "archive" ? (
1297
- <Archive className="w-24 h-24 mb-4 opacity-50" />
1298
- ) : (
1299
- <FileIcon className="w-24 h-24 mb-4 opacity-50" />
1300
- )}
1301
- <span className="text-xl font-bold tracking-wider uppercase text-[var(--kyro-text-secondary)]">
1302
- {getFileExtension(panelItem.filename)}
1303
- </span>
913
+ <div className="w-full h-full flex flex-col items-center justify-center p-12 gap-6">
914
+ <div className="w-20 h-20 rounded-[2rem] bg-[var(--kyro-surface-accent)] flex items-center justify-center">
915
+ <FileIcon className="w-10 h-10 text-[var(--kyro-text-secondary)]" />
916
+ </div>
917
+ <Badge variant="outline" className="text-xs font-bold">
918
+ {panelItem.mimeType}
919
+ </Badge>
1304
920
  </div>
1305
921
  )}
1306
- </div>
1307
- <div className="space-y-4 flex-1">
1308
- <div>
1309
- <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
1310
- Display Name
1311
- </label>
1312
- <input
1313
- type="text"
1314
- value={panelName}
1315
- onChange={(e) => setPanelName(e.target.value)}
1316
- 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)]"
1317
- />
922
+
923
+ <div className="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
924
+ <button
925
+ onClick={() => setShowPreview(true)}
926
+ className="p-2.5 bg-black/50 backdrop-blur-md text-white rounded-xl hover:bg-black/70 transition-all"
927
+ >
928
+ <Maximize2 className="w-4 h-4" />
929
+ </button>
1318
930
  </div>
1319
- <div>
1320
- <label className="block text-sm font-bold text-[var(--kyro-text-secondary)] mb-1">
1321
- Alt Text
1322
- </label>
1323
- <input
1324
- type="text"
1325
- value={panelAlt}
1326
- onChange={(e) => setPanelAlt(e.target.value)}
1327
- placeholder="Accessibility text..."
1328
- 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)]"
1329
- />
931
+ </div>
932
+
933
+ <div className="mt-8 space-y-8 flex-1">
934
+ <div className="grid grid-cols-2 gap-4">
935
+ <div className="p-4 rounded-2xl bg-[var(--kyro-surface-accent)]/50 border border-[var(--kyro-border)]">
936
+ <span className="text-[9px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 block mb-1">
937
+ Dimensions
938
+ </span>
939
+ <span className="text-xs font-bold text-[var(--kyro-text-primary)]">
940
+ {panelItem.type === "image" ? "Original Resolution" : "N/A"}
941
+ </span>
942
+ </div>
943
+ <div className="p-4 rounded-2xl bg-[var(--kyro-surface-accent)]/50 border border-[var(--kyro-border)]">
944
+ <span className="text-[9px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 block mb-1">
945
+ File Size
946
+ </span>
947
+ <span className="text-xs font-bold text-[var(--kyro-text-primary)]">
948
+ {formatFileSize(panelItem.fileSize)}
949
+ </span>
950
+ </div>
1330
951
  </div>
1331
- <div className="pt-4 border-t border-[var(--kyro-border)] space-y-2 text-xs text-[var(--kyro-text-muted)]">
1332
- <p>
1333
- <span className="font-bold">Filename:</span>{" "}
1334
- {panelItem.filename}
1335
- </p>
1336
- <p>
1337
- <span className="font-bold">Type:</span> {panelItem.mimeType}
1338
- </p>
1339
- <p>
1340
- <span className="font-bold">Size:</span>{" "}
1341
- {formatFileSize(panelItem.fileSize)}
1342
- </p>
1343
- <p className="pt-2">
1344
- <span className="font-bold">File URL</span>
1345
- </p>
1346
- <div className="flex gap-2 items-center">
1347
- <input
1348
- type="text"
1349
- readOnly
1350
- value={getAbsoluteUrl(panelItem.url)}
1351
- 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"
1352
- />
1353
- <button
1354
- type="button"
1355
- onClick={() => {
1356
- navigator.clipboard.writeText(
1357
- getAbsoluteUrl(panelItem.url),
1358
- );
1359
- setCopiedId(panelItem.id);
1360
- setTimeout(() => setCopiedId(null), 2000);
1361
- }}
1362
- className={`p-2 border rounded-lg transition-colors shrink-0 ${
1363
- copiedId === panelItem.id
1364
- ? "border-[var(--kyro-success)] bg-[var(--kyro-success-bg)] text-[var(--kyro-success)]"
1365
- : "border-[var(--kyro-border)] hover:bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)]"
1366
- }`}
1367
- title={copiedId === panelItem.id ? "Copied!" : "Copy URL"}
1368
- >
1369
- <Link className="w-4 h-4" />
1370
- </button>
1371
- <a
1372
- href={getAbsoluteUrl(panelItem.url)}
1373
- download={panelItem.filename}
1374
- target="_blank"
1375
- rel="noopener noreferrer"
1376
- className="p-2 border border-[var(--kyro-border)] rounded-lg hover:bg-[var(--kyro-surface-accent)] transition-colors shrink-0"
1377
- title="Download"
1378
- >
1379
- <Download className="w-4 h-4 text-[var(--kyro-text-secondary)]" />
1380
- </a>
952
+
953
+ <div className="space-y-4">
954
+ <div>
955
+ <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 block mb-2 px-1">
956
+ Public Link
957
+ </label>
958
+ <div className="flex gap-2">
959
+ <input
960
+ type="text"
961
+ readOnly
962
+ value={getAbsoluteUrl(panelItem.url)}
963
+ className="flex-1 px-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-xl text-[10px] font-bold font-mono focus:outline-none"
964
+ />
965
+ <button
966
+ onClick={() => {
967
+ navigator.clipboard.writeText(
968
+ getAbsoluteUrl(panelItem.url),
969
+ );
970
+ alert({ title: "Copied", message: "URL copied to clipboard" });
971
+ }}
972
+ className="p-3 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-border)] border border-[var(--kyro-border)] rounded-xl transition-all"
973
+ >
974
+ <Link className="w-4 h-4 text-[var(--kyro-text-primary)]" />
975
+ </button>
976
+ </div>
977
+ </div>
978
+
979
+ <div className="space-y-4">
980
+ <div>
981
+ <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 block mb-2 px-1">
982
+ Alt Text
983
+ </label>
984
+ <textarea
985
+ value={panelItem.alt || ""}
986
+ onChange={(e) =>
987
+ updateMetadata(panelItem.id, { alt: e.target.value })
988
+ }
989
+ className="w-full px-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-xs font-bold transition-all resize-none min-h-[80px] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
990
+ placeholder="Describe this asset..."
991
+ />
992
+ </div>
993
+ <div>
994
+ <label className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-secondary)] opacity-50 block mb-2 px-1">
995
+ Caption
996
+ </label>
997
+ <textarea
998
+ value={panelItem.caption || ""}
999
+ onChange={(e) =>
1000
+ updateMetadata(panelItem.id, {
1001
+ caption: e.target.value,
1002
+ })
1003
+ }
1004
+ className="w-full px-4 py-3 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-lg text-xs font-bold transition-all resize-none min-h-[80px] focus:outline-none focus:ring-2 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
1005
+ placeholder="Add a caption..."
1006
+ />
1007
+ </div>
1381
1008
  </div>
1382
- <p>
1383
- <span className="font-bold">Uploaded:</span>{" "}
1384
- {formatDate(panelItem.createdAt)}
1385
- </p>
1386
1009
  </div>
1387
- {panelItem.type === "image" && (
1010
+ </div>
1011
+
1012
+ <div className="pt-8 border-t border-[var(--kyro-border)] mt-8 flex gap-3 pb-8">
1013
+ <button
1014
+ onClick={() => {
1015
+ const link = document.createElement("a");
1016
+ link.href = getAbsoluteUrl(panelItem.url);
1017
+ link.download = panelItem.filename;
1018
+ link.click();
1019
+ }}
1020
+ className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] rounded-xl font-bold text-xs shadow-lg hover:opacity-90 transition-all"
1021
+ >
1022
+ <Download className="w-4 h-4" />
1023
+ Download
1024
+ </button>
1025
+ {panelItem.type === "image" && canUpdate && (
1388
1026
  <button
1389
- type="button"
1390
1027
  onClick={() => setShowCrop(true)}
1391
- 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"
1028
+ className="p-3 border border-[var(--kyro-border)] rounded-xl text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)] transition-all"
1392
1029
  >
1393
- <CropIcon className="w-3.5 h-3.5" />
1394
- Crop Image
1030
+ <CropIcon className="w-4 h-4" />
1031
+ </button>
1032
+ )}
1033
+ {canDelete && (
1034
+ <button
1035
+ onClick={() => {
1036
+ confirm({
1037
+ title: "Delete Asset",
1038
+ message: `Are you sure you want to delete ${panelItem.filename}? This cannot be undone.`,
1039
+ variant: "danger",
1040
+ onConfirm: async () => {
1041
+ try {
1042
+ await apiDelete(`/api/media/${panelItem.id}`);
1043
+ setPanelItem(null);
1044
+ loadMedia();
1045
+ } catch (error) {
1046
+ console.error("Delete failed:", error);
1047
+ alert({ title: "Error", message: "Failed to delete asset" });
1048
+ }
1049
+ }
1050
+ });
1051
+ }}
1052
+ className="p-3 border border-red-100 text-red-500 rounded-xl hover:bg-red-50 transition-all"
1053
+ >
1054
+ <Trash2 className="w-4 h-4" />
1395
1055
  </button>
1396
1056
  )}
1397
1057
  </div>
1398
- <div className="flex gap-3 mt-6">
1399
- <a
1400
- href={getAbsoluteUrl(panelItem.url)}
1401
- download={panelItem.filename}
1402
- target="_blank"
1403
- rel="noopener noreferrer"
1404
- className="p-2 border border-[var(--kyro-border)] rounded-lg hover:bg-[var(--kyro-surface-accent)] transition-colors"
1405
- title="Download"
1406
- >
1407
- <Download className="w-5 h-5 text-[var(--kyro-text-secondary)]" />
1408
- </a>
1409
- <button
1410
- type="button"
1411
- onClick={savePanelMetadata}
1412
- 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"
1413
- >
1414
- Save
1415
- </button>
1058
+ </div>
1059
+ )}
1060
+ </SlidePanel>
1061
+
1062
+ {/* Preview Modal */}
1063
+ {showPreview &&
1064
+ panelItem &&
1065
+ createPortal(
1066
+ <div className="fixed inset-0 z-[9999] bg-black/95 flex flex-col animate-in fade-in duration-500">
1067
+ <div className="flex items-center justify-between p-6">
1068
+ <div className="flex flex-col">
1069
+ <span className="text-white font-bold text-lg tracking-tight">
1070
+ {panelItem.filename}
1071
+ </span>
1072
+ <span className="text-white/40 text-[10px] font-bold tracking-widest mt-1">
1073
+ {formatFileSize(panelItem.fileSize)} · {panelItem.mimeType}
1074
+ </span>
1075
+ </div>
1416
1076
  <button
1417
- type="button"
1418
- onClick={() => setPanelItem(null)}
1419
- 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)]"
1077
+ onClick={() => setShowPreview(false)}
1078
+ className="p-3 bg-white/10 hover:bg-white/20 text-white rounded-2xl transition-all active:scale-90"
1420
1079
  >
1421
- Cancel
1080
+ <X className="w-6 h-6" />
1422
1081
  </button>
1423
1082
  </div>
1424
- </div>
1425
- </div>
1426
- )}
1083
+ <div className="flex-1 w-full flex items-center justify-center p-12">
1084
+ {panelItem.type === "image" ? (
1085
+ <img
1086
+ src={getAbsoluteUrl(panelItem.url)}
1087
+ alt=""
1088
+ className="max-h-full max-w-full object-contain shadow-2xl rounded-lg animate-in zoom-in-95 duration-500"
1089
+ />
1090
+ ) : panelItem.type === "video" ? (
1091
+ <video
1092
+ src={getAbsoluteUrl(panelItem.url)}
1093
+ controls
1094
+ autoPlay
1095
+ className="max-h-full max-w-full rounded-lg shadow-2xl"
1096
+ />
1097
+ ) : (
1098
+ <div className="text-white text-center">
1099
+ <FileIcon className="w-24 h-24 mx-auto mb-6 opacity-20" />
1100
+ <p className="text-xl font-bold opacity-50">
1101
+ Preview not available for this file type
1102
+ </p>
1103
+ </div>
1104
+ )}
1105
+ </div>
1106
+ </div>,
1107
+ document.body,
1108
+ )}
1427
1109
 
1110
+ {/* Crop Modal */}
1428
1111
  {showCrop &&
1429
1112
  panelItem &&
1430
1113
  createPortal(
1431
- <div className="fixed inset-0 z-[10000] bg-black/95 flex flex-col items-center justify-center p-8">
1432
- <div className="flex w-full justify-between items-center mb-4 text-white">
1433
- <h3 className="text-xl font-bold">Crop Image</h3>
1114
+ <div className="fixed inset-0 z-[9999] bg-black/95 flex flex-col p-8">
1115
+ <div className="flex items-center justify-between mb-8">
1116
+ <h3 className="text-white font-bold text-2xl tracking-tighter">
1117
+ Crop Image
1118
+ </h3>
1434
1119
  <div className="flex gap-3">
1435
1120
  <button
1436
1121
  type="button"
@@ -1453,7 +1138,7 @@ export function MediaGallery() {
1453
1138
  <ReactCrop crop={crop} onChange={(c) => setCrop(c)}>
1454
1139
  <img
1455
1140
  ref={imgRef}
1456
- src={`/api/media/resize?url=${encodeURIComponent(panelItem.url)}`}
1141
+ src={panelItem.url}
1457
1142
  alt="Crop preview"
1458
1143
  className="max-h-[70vh] object-contain"
1459
1144
  onLoad={onImageLoad}
@@ -1463,15 +1148,6 @@ export function MediaGallery() {
1463
1148
  </div>,
1464
1149
  document.body,
1465
1150
  )}
1466
- <ConfirmModal
1467
- open={deleteConfirm.open}
1468
- onClose={() => setDeleteConfirm({ open: false, count: 0 })}
1469
- onConfirm={confirmDelete}
1470
- title="Delete Media"
1471
- message={`Are you sure you want to delete ${deleteConfirm.count} item(s)? This cannot be undone.`}
1472
- confirmLabel="Delete"
1473
- variant="danger"
1474
- />
1475
1151
  <PromptModal
1476
1152
  open={showNewFolderModal}
1477
1153
  onClose={() => setShowNewFolderModal(false)}
@@ -1499,7 +1175,7 @@ export function MediaGallery() {
1499
1175
  />
1500
1176
  </svg>
1501
1177
  </div>
1502
- <h3 className="text-xl font-black text-[var(--kyro-text-primary)] mb-2">
1178
+ <h3 className="text-xl font-bold text-[var(--kyro-text-primary)] mb-2">
1503
1179
  Storage Not Configured
1504
1180
  </h3>
1505
1181
  <p className="text-[var(--kyro-text-secondary)] mb-6 text-sm">
@@ -1518,7 +1194,7 @@ export function MediaGallery() {
1518
1194
  type="button"
1519
1195
  onClick={() => {
1520
1196
  // Set default storage config programmatically
1521
- apiPatch("/api/globals/storage-settings", {
1197
+ apiPost("/api/globals/storage-settings", {
1522
1198
  provider: "local",
1523
1199
  local: {
1524
1200
  uploadDir: "./public/uploads",
@@ -1540,18 +1216,6 @@ export function MediaGallery() {
1540
1216
  </div>,
1541
1217
  document.body,
1542
1218
  )}
1543
- <ConfirmModal
1544
- open={showDeleteFolderModal}
1545
- onClose={() => {
1546
- setShowDeleteFolderModal(false);
1547
- setFolderToDelete("");
1548
- }}
1549
- onConfirm={deleteFolder}
1550
- title="Delete Folder"
1551
- message={`Are you sure you want to delete the folder "${folderToDelete}"? All media in this folder will be moved to the root.`}
1552
- confirmLabel="Delete Folder"
1553
- variant="danger"
1554
- />
1555
1219
  <input
1556
1220
  type="file"
1557
1221
  ref={fileInputRef}