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