@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.
- package/dist/EditorClient-XEUOVAAC.js +466 -0
- package/dist/EditorClient-XEUOVAAC.js.map +1 -0
- package/dist/EditorClient-YLCGVDXY.cjs +468 -0
- package/dist/EditorClient-YLCGVDXY.cjs.map +1 -0
- package/dist/chunk-7KPIUCGT.js +384 -0
- package/dist/chunk-7KPIUCGT.js.map +1 -0
- package/dist/chunk-GOACG6R7.cjs +473 -0
- package/dist/chunk-GOACG6R7.cjs.map +1 -0
- package/dist/index.cjs +14861 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +1661 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +563 -0
- package/dist/index.js +14784 -0
- package/dist/index.js.map +1 -0
- package/package.json +19 -19
- package/src/components/ActionBar.tsx +7 -43
- package/src/components/Admin.tsx +138 -277
- package/src/components/ApiKeysManager.tsx +428 -419
- package/src/components/AuditLogsPage.tsx +35 -39
- package/src/components/AuthBridge.tsx +51 -0
- package/src/components/AutoForm.tsx +495 -1230
- package/src/components/BrandingHub.tsx +18 -19
- package/src/components/BulkActionsBar.tsx +1 -1
- package/src/components/CreateView.tsx +22 -36
- package/src/components/Dashboard.tsx +60 -84
- package/src/components/DetailView.tsx +113 -91
- package/src/components/DeveloperCenter.tsx +200 -198
- package/src/components/FieldRenderer.tsx +206 -0
- package/src/components/GraphQLPlayground.tsx +340 -480
- package/src/components/ListView.tsx +828 -254
- package/src/components/LoginPage.tsx +3 -4
- package/src/components/MarketplaceManager.tsx +254 -0
- package/src/components/MediaGallery.tsx +856 -1192
- package/src/components/PluginsManager.tsx +277 -0
- package/src/components/RestPlayground.tsx +398 -560
- package/src/components/SessionsManager.tsx +211 -0
- package/src/components/Sidebar.astro +179 -151
- package/src/components/ThemeProvider.tsx +7 -161
- package/src/components/UserManagement.tsx +162 -146
- package/src/components/UserMenu.tsx +110 -0
- package/src/components/WebhookManager.tsx +305 -367
- package/src/components/blocks/AccordionBlock.tsx +4 -4
- package/src/components/blocks/ArrayBlock.tsx +3 -3
- package/src/components/blocks/BlockEditModal.tsx +8 -8
- package/src/components/blocks/BlockWrapper.tsx +61 -0
- package/src/components/blocks/ButtonBlock.tsx +4 -4
- package/src/components/blocks/ChildBlocksTree.tsx +23 -25
- package/src/components/blocks/CodeBlock.tsx +15 -15
- package/src/components/blocks/ColumnsBlock.tsx +6 -44
- package/src/components/blocks/DividerBlock.tsx +3 -3
- package/src/components/blocks/FileBlock.tsx +4 -4
- package/src/components/blocks/HeadingBlock.tsx +6 -38
- package/src/components/blocks/HeroBlock.tsx +4 -4
- package/src/components/blocks/ImageBlock.tsx +4 -4
- package/src/components/blocks/LinkBlock.tsx +4 -4
- package/src/components/blocks/ListBlock.tsx +3 -3
- package/src/components/blocks/ParagraphBlock.tsx +12 -42
- package/src/components/blocks/RelationshipBlock.tsx +4 -4
- package/src/components/blocks/RichTextBlock.tsx +4 -4
- package/src/components/blocks/VStackBlock.tsx +5 -37
- package/src/components/blocks/VideoBlock.tsx +4 -4
- package/src/components/blocks/types.ts +11 -0
- package/src/components/fields/AccordionField.tsx +1 -1
- package/src/components/fields/ArrayField.tsx +2 -2
- package/src/components/fields/ArrayLayout.tsx +93 -0
- package/src/components/fields/BlocksField.tsx +122 -111
- package/src/components/fields/ButtonField.tsx +1 -1
- package/src/components/fields/CheckboxField.tsx +14 -15
- package/src/components/fields/ChildrenField.tsx +2 -2
- package/src/components/fields/CodeField.tsx +3 -3
- package/src/components/fields/ColumnsField.tsx +2 -2
- package/src/components/fields/DateField.tsx +13 -26
- package/src/components/fields/EditorClient.tsx +26 -28
- package/src/components/fields/FieldLayout.tsx +52 -0
- package/src/components/fields/GroupLayout.tsx +35 -0
- package/src/components/fields/JSONField.tsx +7 -7
- package/src/components/fields/LinkField.tsx +1 -1
- package/src/components/fields/MarkdownField.tsx +1 -1
- package/src/components/fields/NumberField.tsx +13 -26
- package/src/components/fields/PortableTextField.tsx +4 -4
- package/src/components/fields/PortableTextRenderer.tsx +1 -1
- package/src/components/fields/RelationshipBlockField.tsx +31 -23
- package/src/components/fields/RelationshipField.tsx +14 -14
- package/src/components/fields/SelectField.tsx +17 -26
- package/src/components/fields/TabsLayout.tsx +69 -0
- package/src/components/fields/TextField.tsx +85 -38
- package/src/components/fields/UploadField.tsx +71 -41
- package/src/components/fields/VideoField.tsx +1 -1
- package/src/components/fields/extensions/blockComponents.tsx +2 -2
- package/src/components/fields/extensions/blocksStore.ts +207 -193
- package/src/components/fields/types.ts +22 -0
- package/src/components/layout/Layout.tsx +1 -1
- package/src/components/ui/ActionMenu.tsx +63 -0
- package/src/components/ui/Badge.tsx +59 -5
- package/src/components/ui/BlockDrawer.tsx +4 -5
- package/src/components/ui/CommandPalette.tsx +58 -36
- package/src/components/ui/CommandPaletteWrapper.tsx +18 -17
- package/src/components/ui/Dropdown.tsx +18 -16
- package/src/components/ui/EmptyState.tsx +25 -0
- package/src/components/ui/GlobalModal.tsx +49 -0
- package/src/components/ui/IconButton.tsx +44 -0
- package/src/components/ui/Modal.tsx +19 -20
- package/src/components/ui/PageHeader.tsx +158 -0
- package/src/components/ui/Pagination.tsx +61 -0
- package/src/components/ui/PromptModal.tsx +1 -1
- package/src/components/ui/SearchInput.tsx +57 -0
- package/src/components/ui/SeoPreview.tsx +31 -0
- package/src/components/ui/SessionModal.tsx +0 -0
- package/src/components/ui/SlidePanel.tsx +2 -0
- package/src/components/ui/Toast.tsx +65 -122
- package/src/components/ui/Toaster.tsx +18 -0
- package/src/components/ui/icons.tsx +112 -0
- package/src/components/users/UserDetail.tsx +290 -0
- package/src/components/users/UserForm.tsx +242 -0
- package/src/components/users/UsersList.tsx +338 -0
- package/src/env.d.ts +13 -13
- package/src/fields/index.ts +2 -1
- package/src/global.d.ts +7 -0
- package/src/hooks/data.ts +2 -9
- package/src/hooks/useAsyncData.ts +36 -0
- package/src/hooks/useAutoFormState.ts +527 -0
- package/src/hooks/useSelection.ts +49 -0
- package/src/hooks/useSession.ts +0 -0
- package/src/index.ts +11 -1
- package/src/integration.ts +86 -11
- package/src/kyro-cms.d.ts +209 -0
- package/src/layouts/AdminLayout.astro +128 -11
- package/src/layouts/AuthLayout.astro +21 -5
- package/src/lib/api.ts +175 -55
- package/src/lib/autoform-store.ts +435 -0
- package/src/lib/config.ts +82 -34
- package/src/lib/createRegistry.ts +29 -0
- package/src/lib/default-kyro-config.ts +4 -0
- package/src/lib/globals.ts +50 -0
- package/src/lib/media-utils.ts +18 -0
- package/src/lib/object-utils.ts +77 -0
- package/src/lib/paths.ts +61 -0
- package/src/lib/stores/index.ts +370 -0
- package/src/lib/types.ts +43 -0
- package/src/lib/useResourceManager.ts +105 -0
- package/src/pages/403.astro +67 -0
- package/src/pages/[collection]/[id].astro +14 -180
- package/src/pages/[collection]/index.astro +11 -6
- package/src/pages/api-explorer.astro +173 -0
- package/src/pages/audit/index.astro +2 -0
- package/src/pages/auth/login.astro +122 -0
- package/src/pages/auth/register.astro +167 -0
- package/src/pages/graphql-explorer.astro +59 -0
- package/src/pages/{admin/graphql.astro → graphql.astro} +51 -17
- package/src/pages/index.astro +577 -0
- package/src/pages/index_ALT.astro +3 -0
- package/src/pages/keys.astro +11 -0
- package/src/pages/marketplace.astro +11 -0
- package/src/pages/media.astro +3 -0
- package/src/pages/plugins.astro +8 -0
- package/src/pages/preview/[collection]/[id].astro +188 -123
- package/src/pages/rest-playground.astro +62 -0
- package/src/pages/roles/index.astro +183 -76
- package/src/pages/sessions.astro +8 -0
- package/src/pages/settings/[slug].astro +92 -114
- package/src/pages/settings/index.astro +5 -3
- package/src/pages/users/[id].astro +25 -154
- package/src/pages/users/index.astro +19 -130
- package/src/pages/users/new.astro +9 -86
- package/src/pages/webhooks.astro +11 -0
- package/src/routes.ts +80 -0
- package/src/styles/main.css +119 -79
- package/src/theme/tokens.ts +1 -0
- package/src/vite-env.d.ts +14 -0
- package/src/collections/auth/index.ts +0 -155
- package/src/collections/portfolio/index.ts +0 -343
- package/src/components/ApiExplorer.tsx +0 -325
- package/src/components/EnhancedListView.tsx +0 -889
- package/src/components/GraphQLExplorer.tsx +0 -675
- package/src/components/Icons.tsx +0 -23
- package/src/components/StatusBadge.tsx +0 -76
- package/src/lib/MediaService.ts +0 -541
- package/src/lib/auth/sqlite-adapter.ts +0 -319
- package/src/lib/dataStore.ts +0 -226
- package/src/lib/db/adapter.ts +0 -54
- package/src/lib/db/drizzle-mysql-adapter.ts +0 -194
- package/src/lib/db/drizzle-mysql-auth-adapter.ts +0 -327
- package/src/lib/db/drizzle-postgres-adapter.ts +0 -202
- package/src/lib/db/drizzle-postgres-auth-adapter.ts +0 -304
- package/src/lib/db/drizzle-sqlite-adapter.ts +0 -227
- package/src/lib/db/drizzle-sqlite-auth-adapter.ts +0 -548
- package/src/lib/db/index.ts +0 -449
- package/src/lib/db/mongodb-adapter.ts +0 -207
- package/src/lib/db/mongodb-auth-adapter.ts +0 -305
- package/src/lib/db/schema/mysql-auth.ts +0 -113
- package/src/lib/db/schema/mysql-content.ts +0 -20
- package/src/lib/db/schema/postgres-auth.ts +0 -116
- package/src/lib/db/schema/postgres-content.ts +0 -35
- package/src/lib/db/schema/postgres-media.ts +0 -52
- package/src/lib/db/schema/postgres-settings.ts +0 -11
- package/src/lib/db/schema/sqlite-auth.ts +0 -112
- package/src/lib/db/schema/sqlite-content.ts +0 -20
- package/src/lib/db/version-adapter.ts +0 -248
- package/src/lib/graphql/index.ts +0 -1
- package/src/lib/graphql/schema.ts +0 -443
- package/src/lib/rate-limit.ts +0 -267
- package/src/lib/storage.ts +0 -374
- package/src/lib/store.ts +0 -85
- package/src/middleware.ts +0 -177
- package/src/pages/admin/api-explorer.astro +0 -98
- package/src/pages/admin/graphql-explorer.astro +0 -40
- package/src/pages/admin/index.astro +0 -286
- package/src/pages/admin/keys.astro +0 -8
- package/src/pages/admin/rest-playground.astro +0 -44
- package/src/pages/admin/webhooks.astro +0 -8
- package/src/pages/api/[collection]/[id]/publish.ts +0 -52
- package/src/pages/api/[collection]/[id]/unpublish.ts +0 -42
- package/src/pages/api/[collection]/[id]/versions.ts +0 -66
- package/src/pages/api/[collection]/[id].ts +0 -213
- package/src/pages/api/[collection]/index.ts +0 -209
- package/src/pages/api/auth/[id].ts +0 -121
- package/src/pages/api/auth/audit-logs.ts +0 -57
- package/src/pages/api/auth/login.ts +0 -211
- package/src/pages/api/auth/logout.ts +0 -66
- package/src/pages/api/auth/me.ts +0 -36
- package/src/pages/api/auth/refresh.ts +0 -119
- package/src/pages/api/auth/register.ts +0 -188
- package/src/pages/api/auth/users.ts +0 -97
- package/src/pages/api/collections.ts +0 -59
- package/src/pages/api/globals/[slug].ts +0 -42
- package/src/pages/api/graphql.ts +0 -90
- package/src/pages/api/health.ts +0 -426
- package/src/pages/api/keys/[id].ts +0 -26
- package/src/pages/api/keys/index.ts +0 -75
- package/src/pages/api/media/[id].ts +0 -309
- package/src/pages/api/media/folders.ts +0 -609
- package/src/pages/api/media/index.ts +0 -146
- package/src/pages/api/media/resize.ts +0 -267
- package/src/pages/api/search.ts +0 -82
- package/src/pages/api/slug-availability.ts +0 -70
- package/src/pages/api/storage-config.ts +0 -20
- package/src/pages/api/storage-status.ts +0 -206
- package/src/pages/api/upload.ts +0 -334
- package/src/pages/api/webhooks/index.ts +0 -71
- package/src/pages/login.astro +0 -82
- 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 "
|
|
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("
|
|
103
|
+
mimeType.includes("archive")
|
|
102
104
|
)
|
|
103
105
|
return "archive";
|
|
104
106
|
if (
|
|
105
107
|
mimeType.includes("pdf") ||
|
|
106
|
-
mimeType.includes("
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 [
|
|
126
|
-
const [
|
|
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 [
|
|
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 [
|
|
144
|
-
const [
|
|
145
|
-
const [
|
|
146
|
-
const [
|
|
147
|
-
|
|
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 [
|
|
150
|
-
|
|
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 [
|
|
154
|
-
const [
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
} catch (
|
|
190
|
-
console.error("
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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(
|
|
254
|
-
}, [
|
|
208
|
+
loadMedia();
|
|
209
|
+
}, [loadMedia]);
|
|
255
210
|
|
|
256
211
|
useEffect(() => {
|
|
257
212
|
loadFolders();
|
|
258
213
|
}, [loadFolders]);
|
|
259
214
|
|
|
260
215
|
useEffect(() => {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
);
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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 <
|
|
290
|
-
const file =
|
|
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 (
|
|
240
|
+
if (currentFolder) formData.append("folder", currentFolder);
|
|
299
241
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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(`
|
|
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(
|
|
319
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
(
|
|
368
|
-
|
|
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
|
|
375
|
-
e.
|
|
376
|
-
|
|
377
|
-
if (
|
|
378
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
431
|
-
if (!folderToDelete) return;
|
|
322
|
+
const updateMetadata = async (id: string, data: Partial<MediaItem>) => {
|
|
432
323
|
try {
|
|
433
|
-
const result = await
|
|
434
|
-
|
|
435
|
-
)
|
|
436
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 (!
|
|
345
|
+
if (!crop || !imgRef.current || !panelItem) return;
|
|
469
346
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
347
|
+
setUploading(true);
|
|
348
|
+
try {
|
|
349
|
+
const pixelCrop = convertToPixelCrop(
|
|
350
|
+
crop,
|
|
351
|
+
imgRef.current.width,
|
|
352
|
+
imgRef.current.height,
|
|
353
|
+
);
|
|
476
354
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
359
|
+
canvas.width = pixelCrop.width;
|
|
360
|
+
canvas.height = pixelCrop.height;
|
|
361
|
+
const ctx = canvas.getContext("2d");
|
|
484
362
|
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error("Crop failed:", err);
|
|
392
|
+
} finally {
|
|
393
|
+
setUploading(false);
|
|
394
|
+
}
|
|
526
395
|
};
|
|
527
396
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
|
552
|
-
{
|
|
553
|
-
{
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
459
|
+
|
|
460
|
+
<div className="flex bg-[var(--kyro-surface-accent)] p-1 rounded-xl border border-[var(--kyro-border)]">
|
|
605
461
|
<button
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
<
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
692
|
-
onClick={() =>
|
|
693
|
-
className=
|
|
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
|
-
<
|
|
697
|
-
className=
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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
|
-
|
|
987
|
-
|
|
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
|
-
<
|
|
995
|
-
|
|
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
|
-
|
|
1009
|
-
<
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
strokeLinejoin="round"
|
|
1037
|
-
strokeWidth={2}
|
|
1038
|
-
d="M5 13l4 4L19 7"
|
|
1039
|
-
/>
|
|
1040
|
-
</svg>
|
|
592
|
+
Start Uploading
|
|
593
|
+
</button>
|
|
1041
594
|
)}
|
|
1042
|
-
</
|
|
1043
|
-
|
|
1044
|
-
<
|
|
1045
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
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={
|
|
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
|
-
<
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1127
|
-
onClick={(
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1150
|
-
onClick={() =>
|
|
1151
|
-
className="
|
|
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
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
801
|
+
</div>
|
|
802
|
+
)}
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
1171
805
|
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
className="
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
844
|
+
</div>
|
|
845
|
+
)}
|
|
1182
846
|
|
|
1183
|
-
{
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
<
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1230
|
-
</
|
|
862
|
+
Clear all
|
|
863
|
+
</button>
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
<div className="flex items-center gap-4">
|
|
867
|
+
{onSelect && (
|
|
1231
868
|
<button
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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-
|
|
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
|
-
|
|
875
|
+
Confirm Selection
|
|
1244
876
|
</button>
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
1281
|
-
|
|
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.
|
|
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
|
|
1290
|
-
|
|
1291
|
-
<
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
<
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
<
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
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="
|
|
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-
|
|
1394
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
className="
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
</
|
|
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
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
1080
|
+
<X className="w-6 h-6" />
|
|
1422
1081
|
</button>
|
|
1423
1082
|
</div>
|
|
1424
|
-
|
|
1425
|
-
|
|
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-[
|
|
1432
|
-
<div className="flex
|
|
1433
|
-
<h3 className="text-
|
|
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={
|
|
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-
|
|
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
|
-
|
|
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}
|