@bygghammar/cms-platform 0.16.0 → 0.17.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -97,7 +97,10 @@
|
|
|
97
97
|
"./migrations/*": "./migrations/*",
|
|
98
98
|
"./backoffice/seo/PageSeoPanelView": "./src/backoffice/seo/PageSeoPanelView.tsx",
|
|
99
99
|
"./backoffice/SectionFormView": "./src/backoffice/SectionFormView.tsx",
|
|
100
|
-
"./backoffice/PageEditorView": "./src/backoffice/PageEditorView.tsx"
|
|
100
|
+
"./backoffice/PageEditorView": "./src/backoffice/PageEditorView.tsx",
|
|
101
|
+
"./media/pick-video-poster": "./src/media/pick-video-poster.tsx",
|
|
102
|
+
"./media/library-types": "./src/media/library-types.ts",
|
|
103
|
+
"./backoffice/MediaLibraryView": "./src/backoffice/MediaLibraryView.tsx"
|
|
101
104
|
},
|
|
102
105
|
"dependencies": {
|
|
103
106
|
"fflate": "^0.8.3"
|
|
@@ -170,4 +173,4 @@
|
|
|
170
173
|
"optional": true
|
|
171
174
|
}
|
|
172
175
|
}
|
|
173
|
-
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import { useRef, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Upload,
|
|
4
|
+
Trash2,
|
|
5
|
+
Copy,
|
|
6
|
+
Search,
|
|
7
|
+
Loader2,
|
|
8
|
+
Zap,
|
|
9
|
+
Link2,
|
|
10
|
+
X,
|
|
11
|
+
Download,
|
|
12
|
+
Crosshair,
|
|
13
|
+
RotateCcw,
|
|
14
|
+
Tags,
|
|
15
|
+
Replace,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import { toast } from "sonner";
|
|
18
|
+
|
|
19
|
+
import { optimizeForUpload } from "../media/optimize";
|
|
20
|
+
import { pickVideoPoster } from "../media/pick-video-poster";
|
|
21
|
+
import { cleanFileName, splitExt, uniqueFileName } from "../media/filename";
|
|
22
|
+
import { hasMediaMeta, lookupMediaMeta, type MediaMetaMap } from "../media/media-meta";
|
|
23
|
+
import type { MediaUsage, MediaUsageReport, MediaImportResult } from "../media/usage-types";
|
|
24
|
+
import type { MediaLibraryItem, MediaTrashItem } from "../media/library-types";
|
|
25
|
+
import { ConfirmDialog } from "./media/ConfirmDialog";
|
|
26
|
+
|
|
27
|
+
/** Everything the host site injects into the shared Media Library screen. */
|
|
28
|
+
export type MediaLibraryDeps = {
|
|
29
|
+
/** Library items, newest first, already loaded by the host. */
|
|
30
|
+
items: MediaLibraryItem[];
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
trash: MediaTrashItem[];
|
|
33
|
+
trashLoading: boolean;
|
|
34
|
+
usage: MediaUsageReport | undefined;
|
|
35
|
+
/** Whether the signed-in user may permanently delete / run maintenance. */
|
|
36
|
+
isSuperadmin: boolean;
|
|
37
|
+
metaMap: MediaMetaMap | undefined;
|
|
38
|
+
|
|
39
|
+
/* Host server calls */
|
|
40
|
+
deleteMedia: (input: { data: { path: string } }) => Promise<unknown>;
|
|
41
|
+
restoreMedia: (input: { data: { id: string } }) => Promise<unknown>;
|
|
42
|
+
purgeMedia: (input: { data: { id: string } }) => Promise<unknown>;
|
|
43
|
+
emptyTrash: () => Promise<{ purged: number }>;
|
|
44
|
+
uploadMediaBundle: (input: {
|
|
45
|
+
data: { files: { name: string; contentType: string; bytes: number[] }[] };
|
|
46
|
+
}) => Promise<unknown>;
|
|
47
|
+
importUrlToMedia: (input: { data: { url: string } }) => Promise<MediaImportResult>;
|
|
48
|
+
/** Invalidate library / trash / usage queries after a mutation. */
|
|
49
|
+
refreshAll: () => Promise<void>;
|
|
50
|
+
invalidateLibrary: () => Promise<void>;
|
|
51
|
+
|
|
52
|
+
/* Host components */
|
|
53
|
+
FocalImageComponent: ComponentType<{
|
|
54
|
+
src: string;
|
|
55
|
+
alt: string;
|
|
56
|
+
className?: string;
|
|
57
|
+
responsive?: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
MediaMetaDialogComponent: ComponentType<{
|
|
60
|
+
src: string;
|
|
61
|
+
title?: string;
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
}>;
|
|
64
|
+
FocusPickerComponent: ComponentType<{
|
|
65
|
+
src: string;
|
|
66
|
+
title?: string;
|
|
67
|
+
modal?: boolean;
|
|
68
|
+
onClose: () => void;
|
|
69
|
+
}>;
|
|
70
|
+
MediaReplaceDialogComponent: ComponentType<{
|
|
71
|
+
source: MediaLibraryItem;
|
|
72
|
+
items: MediaLibraryItem[];
|
|
73
|
+
usageCount: number;
|
|
74
|
+
onClose: () => void;
|
|
75
|
+
onDone: () => Promise<void> | void;
|
|
76
|
+
}>;
|
|
77
|
+
/** Superadmin maintenance tools, rendered next to the Upload button. */
|
|
78
|
+
maintenanceTools?: (args: { onDone: () => Promise<void> }) => ReactNode;
|
|
79
|
+
/** Router link used by the usage lists. */
|
|
80
|
+
LinkComponent: ComponentType<{
|
|
81
|
+
to: string;
|
|
82
|
+
className?: string;
|
|
83
|
+
onClick?: () => void;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
}>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function humanBytes(n: number) {
|
|
89
|
+
if (n < 1024) return `${n} B`;
|
|
90
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
91
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Upload date of a library item, whichever record we have. */
|
|
95
|
+
function itemDate(item: MediaLibraryItem): Date | null {
|
|
96
|
+
const raw = item.uploadedAt ?? item.updatedAt;
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const d = new Date(raw);
|
|
99
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `YYYY-MM` bucket used by the month/year filter. */
|
|
103
|
+
function monthKey(item: MediaLibraryItem): string | null {
|
|
104
|
+
const d = itemDate(item);
|
|
105
|
+
if (!d) return null;
|
|
106
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `YEAR-MONTH-DATE` shown on the card. */
|
|
110
|
+
function isoDate(item: MediaLibraryItem): string | null {
|
|
111
|
+
const d = itemDate(item);
|
|
112
|
+
if (!d) return null;
|
|
113
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
|
114
|
+
d.getDate(),
|
|
115
|
+
).padStart(2, "0")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MONTH_NAMES = [
|
|
119
|
+
"January",
|
|
120
|
+
"February",
|
|
121
|
+
"March",
|
|
122
|
+
"April",
|
|
123
|
+
"May",
|
|
124
|
+
"June",
|
|
125
|
+
"July",
|
|
126
|
+
"August",
|
|
127
|
+
"September",
|
|
128
|
+
"October",
|
|
129
|
+
"November",
|
|
130
|
+
"December",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
function monthLabel(key: string) {
|
|
134
|
+
const [y, m] = key.split("-");
|
|
135
|
+
return `${MONTH_NAMES[Number(m) - 1] ?? m} ${y}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PAGE_SIZE = 50;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Keep the uploaded filename (SEO-relevant) — slugified, extension stripped so
|
|
142
|
+
* the optimizer appends the final one. Collisions get `-2`, `-3`, … never a timestamp.
|
|
143
|
+
*/
|
|
144
|
+
function uploadBaseName(fileName: string, taken: Set<string>) {
|
|
145
|
+
const { base } = splitExt(cleanFileName(fileName));
|
|
146
|
+
return uniqueFileName(base, taken);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Host-agnostic Media Library: upload with client-side optimization, month
|
|
151
|
+
* filter, search, multi-select bulk actions, usage inspection, bin/restore and
|
|
152
|
+
* external-media import. All data access is injected by the host site.
|
|
153
|
+
* Backoffice copy is English by project rule.
|
|
154
|
+
*/
|
|
155
|
+
export function MediaLibraryView({ deps }: { deps: MediaLibraryDeps }) {
|
|
156
|
+
const {
|
|
157
|
+
items,
|
|
158
|
+
isLoading,
|
|
159
|
+
trash,
|
|
160
|
+
trashLoading,
|
|
161
|
+
usage,
|
|
162
|
+
isSuperadmin: isSuper,
|
|
163
|
+
metaMap,
|
|
164
|
+
deleteMedia: del,
|
|
165
|
+
restoreMedia: restore,
|
|
166
|
+
purgeMedia: purge,
|
|
167
|
+
emptyTrash: empty,
|
|
168
|
+
uploadMediaBundle: uploadBundle,
|
|
169
|
+
importUrlToMedia: runImport,
|
|
170
|
+
refreshAll,
|
|
171
|
+
invalidateLibrary,
|
|
172
|
+
FocalImageComponent: FocalImage,
|
|
173
|
+
MediaMetaDialogComponent: MediaMetaDialog,
|
|
174
|
+
FocusPickerComponent: FocusPicker,
|
|
175
|
+
MediaReplaceDialogComponent: MediaReplaceDialog,
|
|
176
|
+
maintenanceTools,
|
|
177
|
+
LinkComponent: Link,
|
|
178
|
+
} = deps;
|
|
179
|
+
|
|
180
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
181
|
+
const [uploading, setUploading] = useState(false);
|
|
182
|
+
const [progress, setProgress] = useState<string | null>(null);
|
|
183
|
+
const [percent, setPercent] = useState(0);
|
|
184
|
+
const [filter, setFilter] = useState("");
|
|
185
|
+
const [month, setMonth] = useState("all");
|
|
186
|
+
const [visible, setVisible] = useState(PAGE_SIZE);
|
|
187
|
+
|
|
188
|
+
const [usageFor, setUsageFor] = useState<{ title: string; usages: MediaUsage[] } | null>(null);
|
|
189
|
+
const [importingUrl, setImportingUrl] = useState<string | null>(null);
|
|
190
|
+
const [focusFor, setFocusFor] = useState<string | null>(null);
|
|
191
|
+
const [metaFor, setMetaFor] = useState<string | null>(null);
|
|
192
|
+
const [view, setView] = useState<"library" | "bin">("library");
|
|
193
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
194
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
195
|
+
const [bulkBusy, setBulkBusy] = useState(false);
|
|
196
|
+
const [confirmBulk, setConfirmBulk] = useState<null | "delete">(null);
|
|
197
|
+
const [confirmOne, setConfirmOne] = useState<MediaLibraryItem | null>(null);
|
|
198
|
+
const [replaceFor, setReplaceFor] = useState<MediaLibraryItem | null>(null);
|
|
199
|
+
|
|
200
|
+
// Bumped after a bulk overwrite so the grid shows the new files instead of
|
|
201
|
+
// the CDN-cached old ones. Stored URLs in content stay clean.
|
|
202
|
+
const [bust, setBust] = useState(0);
|
|
203
|
+
const bustUrl = (url: string) => (bust ? `${url}${url.includes("?") ? "&" : "?"}v=${bust}` : url);
|
|
204
|
+
|
|
205
|
+
const onRestore = async (item: MediaTrashItem) => {
|
|
206
|
+
setBusyId(item.id);
|
|
207
|
+
try {
|
|
208
|
+
await restore({ data: { id: item.id } });
|
|
209
|
+
toast.success("Restored");
|
|
210
|
+
await refreshAll();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
toast.error(e instanceof Error ? e.message : "Restore failed");
|
|
213
|
+
} finally {
|
|
214
|
+
setBusyId(null);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onPurge = async (item: MediaTrashItem) => {
|
|
219
|
+
if (!confirm(`Permanently delete "${item.originalPath}"? This cannot be undone.`)) return;
|
|
220
|
+
setBusyId(item.id);
|
|
221
|
+
try {
|
|
222
|
+
await purge({ data: { id: item.id } });
|
|
223
|
+
toast.success("Permanently deleted");
|
|
224
|
+
await refreshAll();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
toast.error(e instanceof Error ? e.message : "Delete failed");
|
|
227
|
+
} finally {
|
|
228
|
+
setBusyId(null);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const onEmptyBin = async () => {
|
|
233
|
+
if (
|
|
234
|
+
!confirm(`Permanently delete all ${trash.length} item(s) in the bin? This cannot be undone.`)
|
|
235
|
+
)
|
|
236
|
+
return;
|
|
237
|
+
setBusyId("all");
|
|
238
|
+
try {
|
|
239
|
+
const res = await empty();
|
|
240
|
+
toast.success(`Bin emptied (${res.purged} item(s))`);
|
|
241
|
+
await refreshAll();
|
|
242
|
+
} catch (e) {
|
|
243
|
+
toast.error(e instanceof Error ? e.message : "Empty bin failed");
|
|
244
|
+
} finally {
|
|
245
|
+
setBusyId(null);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Month/year buckets (WordPress-style), newest first, with per-month counts.
|
|
250
|
+
const monthCounts = new Map<string, number>();
|
|
251
|
+
for (const i of items) {
|
|
252
|
+
const key = monthKey(i);
|
|
253
|
+
if (key) monthCounts.set(key, (monthCounts.get(key) ?? 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
const months = [...monthCounts.keys()].sort((a, b) => b.localeCompare(a));
|
|
256
|
+
const undatedCount = items.length - [...monthCounts.values()].reduce((a, b) => a + b, 0);
|
|
257
|
+
|
|
258
|
+
const filtered = items.filter((i) => {
|
|
259
|
+
if (!i.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
|
260
|
+
if (month === "all") return true;
|
|
261
|
+
if (month === "unknown") return monthKey(i) === null;
|
|
262
|
+
return monthKey(i) === month;
|
|
263
|
+
});
|
|
264
|
+
const imageCount = filtered.filter((i) => (i.contentType ?? "").startsWith("image/")).length;
|
|
265
|
+
const videoCount = filtered.filter((i) => (i.contentType ?? "").startsWith("video/")).length;
|
|
266
|
+
const otherCount = filtered.length - imageCount - videoCount;
|
|
267
|
+
const shown = filtered.slice(0, visible);
|
|
268
|
+
|
|
269
|
+
const usageMap = usage?.byPath ?? {};
|
|
270
|
+
const externals = usage?.external ?? [];
|
|
271
|
+
|
|
272
|
+
const onUpload = async (files: FileList | null) => {
|
|
273
|
+
if (!files?.length) return;
|
|
274
|
+
setUploading(true);
|
|
275
|
+
try {
|
|
276
|
+
let totalSaved = 0;
|
|
277
|
+
const arr = Array.from(files);
|
|
278
|
+
// Names already in the library (plus the ones taken during this batch).
|
|
279
|
+
const taken = new Set<string>(
|
|
280
|
+
items.flatMap((i) => {
|
|
281
|
+
const { base } = splitExt(i.name);
|
|
282
|
+
return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
const stepPct = (idx: number, frac: number) =>
|
|
286
|
+
setPercent(Math.round(((idx + frac) / arr.length) * 100));
|
|
287
|
+
for (let idx = 0; idx < arr.length; idx++) {
|
|
288
|
+
const file = arr[idx];
|
|
289
|
+
// Videos: let the editor choose which frame becomes the poster.
|
|
290
|
+
const posterTime = file.type.startsWith("video/") ? await pickVideoPoster(file) : null;
|
|
291
|
+
setProgress(`Optimizing ${idx + 1}/${arr.length}: ${file.name}`);
|
|
292
|
+
stepPct(idx, 0.05);
|
|
293
|
+
const baseSlug = uploadBaseName(file.name, taken);
|
|
294
|
+
taken.add(baseSlug);
|
|
295
|
+
const bundle = await optimizeForUpload(file, baseSlug, {
|
|
296
|
+
posterTime: posterTime ?? undefined,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
setProgress(`Uploading ${idx + 1}/${arr.length}: ${file.name}`);
|
|
300
|
+
stepPct(idx, 0.4);
|
|
301
|
+
const all = [bundle.primary, ...bundle.extras];
|
|
302
|
+
const payload = await Promise.all(
|
|
303
|
+
all.map(async (part) => ({
|
|
304
|
+
name: part.name,
|
|
305
|
+
contentType: part.contentType,
|
|
306
|
+
bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
|
|
307
|
+
})),
|
|
308
|
+
);
|
|
309
|
+
await uploadBundle({ data: { files: payload } });
|
|
310
|
+
totalSaved += Math.max(0, file.size - bundle.primary.blob.size);
|
|
311
|
+
const extraTotal = bundle.extras.length || 1;
|
|
312
|
+
let extraDone = 0;
|
|
313
|
+
for (const _ of bundle.extras) {
|
|
314
|
+
extraDone += 1;
|
|
315
|
+
stepPct(idx, 0.4 + (0.6 * extraDone) / (extraTotal + 1));
|
|
316
|
+
}
|
|
317
|
+
stepPct(idx, 1);
|
|
318
|
+
}
|
|
319
|
+
const savedTxt = totalSaved > 1024 ? ` (saved ${humanBytes(totalSaved)})` : "";
|
|
320
|
+
toast.success(`Uploaded ${arr.length} file${arr.length > 1 ? "s" : ""}${savedTxt}`);
|
|
321
|
+
await invalidateLibrary();
|
|
322
|
+
} catch (e) {
|
|
323
|
+
toast.error(e instanceof Error ? e.message : "Upload failed");
|
|
324
|
+
} finally {
|
|
325
|
+
setUploading(false);
|
|
326
|
+
setProgress(null);
|
|
327
|
+
setPercent(0);
|
|
328
|
+
if (fileInput.current) fileInput.current.value = "";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const doDelete = async (item: MediaLibraryItem) => {
|
|
333
|
+
try {
|
|
334
|
+
await del({ data: { path: item.path } });
|
|
335
|
+
toast.success("Moved to bin");
|
|
336
|
+
setSelected((s) => s.filter((p) => p !== item.path));
|
|
337
|
+
await refreshAll();
|
|
338
|
+
} catch (e) {
|
|
339
|
+
toast.error(e instanceof Error ? e.message : "Delete failed");
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/* ---------------- Multi-select ---------------- */
|
|
344
|
+
|
|
345
|
+
const selectedItems = items.filter((i) => selected.includes(i.path));
|
|
346
|
+
const selectedUses = selectedItems.reduce((n, i) => n + (usageMap[i.path]?.length ?? 0), 0);
|
|
347
|
+
const allShownSelected = shown.length > 0 && shown.every((i) => selected.includes(i.path));
|
|
348
|
+
|
|
349
|
+
const toggleOne = (path: string) =>
|
|
350
|
+
setSelected((s) => (s.includes(path) ? s.filter((p) => p !== path) : [...s, path]));
|
|
351
|
+
|
|
352
|
+
const toggleAllShown = () =>
|
|
353
|
+
setSelected((s) =>
|
|
354
|
+
allShownSelected
|
|
355
|
+
? s.filter((p) => !shown.some((i) => i.path === p))
|
|
356
|
+
: [...new Set([...s, ...shown.map((i) => i.path)])],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const bulkDelete = async () => {
|
|
360
|
+
setBulkBusy(true);
|
|
361
|
+
let ok = 0;
|
|
362
|
+
const failed: string[] = [];
|
|
363
|
+
try {
|
|
364
|
+
for (const item of selectedItems) {
|
|
365
|
+
try {
|
|
366
|
+
await del({ data: { path: item.path } });
|
|
367
|
+
ok += 1;
|
|
368
|
+
} catch {
|
|
369
|
+
failed.push(item.name);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await refreshAll();
|
|
373
|
+
if (ok) toast.success(`Moved ${ok} file${ok > 1 ? "s" : ""} to the bin`);
|
|
374
|
+
if (failed.length) toast.error(`Failed: ${failed.join(", ")}`);
|
|
375
|
+
setSelected([]);
|
|
376
|
+
} finally {
|
|
377
|
+
setBulkBusy(false);
|
|
378
|
+
setConfirmBulk(null);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const bulkCopyUrls = async () => {
|
|
383
|
+
try {
|
|
384
|
+
await navigator.clipboard.writeText(selectedItems.map((i) => i.url).join("\n"));
|
|
385
|
+
toast.success(`Copied ${selectedItems.length} URL(s)`);
|
|
386
|
+
} catch {
|
|
387
|
+
toast.error("Copy failed");
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const bulkDownload = () => {
|
|
392
|
+
for (const item of selectedItems) {
|
|
393
|
+
const a = document.createElement("a");
|
|
394
|
+
a.href = item.url;
|
|
395
|
+
a.download = item.name;
|
|
396
|
+
a.target = "_blank";
|
|
397
|
+
a.rel = "noopener";
|
|
398
|
+
document.body.appendChild(a);
|
|
399
|
+
a.click();
|
|
400
|
+
a.remove();
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const onCopy = async (url: string) => {
|
|
405
|
+
try {
|
|
406
|
+
await navigator.clipboard.writeText(url);
|
|
407
|
+
toast.success("URL copied");
|
|
408
|
+
} catch {
|
|
409
|
+
toast.error("Copy failed");
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const onMaintenanceDone = async () => {
|
|
414
|
+
setBust(Date.now());
|
|
415
|
+
await refreshAll();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const importAndRefresh = async (url: string, announce: boolean) => {
|
|
419
|
+
const result = await runImport({ data: { url } });
|
|
420
|
+
if (result.skipped) toast.error(result.error);
|
|
421
|
+
else if (announce) toast.success("Imported");
|
|
422
|
+
return result;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<div className="space-y-6">
|
|
427
|
+
<header className="flex flex-wrap items-end justify-between gap-4">
|
|
428
|
+
<div>
|
|
429
|
+
<h1 className="text-2xl font-serif tracking-tight text-ink">Media Library</h1>
|
|
430
|
+
<p className="text-sm text-ink/60 mt-1">
|
|
431
|
+
Upload and manage images, videos and files. Signed URLs are used for delivery.
|
|
432
|
+
</p>
|
|
433
|
+
</div>
|
|
434
|
+
<div className="flex items-start gap-2">
|
|
435
|
+
<select
|
|
436
|
+
value={month}
|
|
437
|
+
onChange={(e) => {
|
|
438
|
+
setMonth(e.target.value);
|
|
439
|
+
setVisible(PAGE_SIZE);
|
|
440
|
+
}}
|
|
441
|
+
className="border border-ink/15 px-2 py-1.5 text-sm bg-white text-ink focus:outline-none focus:border-sea"
|
|
442
|
+
title="Filter by upload month"
|
|
443
|
+
>
|
|
444
|
+
<option value="all">All dates ({items.length})</option>
|
|
445
|
+
{months.map((m) => (
|
|
446
|
+
<option key={m} value={m}>
|
|
447
|
+
{monthLabel(m)} ({monthCounts.get(m)})
|
|
448
|
+
</option>
|
|
449
|
+
))}
|
|
450
|
+
{undatedCount > 0 ? (
|
|
451
|
+
<option value="unknown">Unknown date ({undatedCount})</option>
|
|
452
|
+
) : null}
|
|
453
|
+
</select>
|
|
454
|
+
<div className="relative">
|
|
455
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-ink/40" />
|
|
456
|
+
<input
|
|
457
|
+
type="text"
|
|
458
|
+
value={filter}
|
|
459
|
+
placeholder="Search…"
|
|
460
|
+
onChange={(e) => {
|
|
461
|
+
setFilter(e.target.value);
|
|
462
|
+
setVisible(PAGE_SIZE);
|
|
463
|
+
}}
|
|
464
|
+
className="border border-ink/15 pl-8 pr-3 py-1.5 text-sm bg-white focus:outline-none focus:border-sea"
|
|
465
|
+
/>
|
|
466
|
+
</div>
|
|
467
|
+
<input
|
|
468
|
+
ref={fileInput}
|
|
469
|
+
type="file"
|
|
470
|
+
multiple
|
|
471
|
+
className="hidden"
|
|
472
|
+
onChange={(e) => onUpload(e.target.files)}
|
|
473
|
+
/>
|
|
474
|
+
<button
|
|
475
|
+
onClick={() => fileInput.current?.click()}
|
|
476
|
+
disabled={uploading}
|
|
477
|
+
className="inline-flex items-center gap-2 bg-ink text-paper px-4 py-2 text-sm hover:bg-sea disabled:opacity-50"
|
|
478
|
+
>
|
|
479
|
+
{uploading ? (
|
|
480
|
+
<Loader2 className="size-4 animate-spin" />
|
|
481
|
+
) : (
|
|
482
|
+
<Upload className="size-4" />
|
|
483
|
+
)}
|
|
484
|
+
Upload
|
|
485
|
+
</button>
|
|
486
|
+
{isSuper ? maintenanceTools?.({ onDone: onMaintenanceDone }) : null}
|
|
487
|
+
</div>
|
|
488
|
+
</header>
|
|
489
|
+
|
|
490
|
+
<div className="flex items-center gap-1 border-b border-ink/10">
|
|
491
|
+
{(["library", "bin"] as const).map((v) => (
|
|
492
|
+
<button
|
|
493
|
+
key={v}
|
|
494
|
+
type="button"
|
|
495
|
+
onClick={() => setView(v)}
|
|
496
|
+
className={[
|
|
497
|
+
"-mb-px border-b-2 px-3 py-2 text-[0.7rem] uppercase tracking-[0.22em]",
|
|
498
|
+
view === v ? "border-sea text-ink" : "border-transparent text-ink/50 hover:text-ink",
|
|
499
|
+
].join(" ")}
|
|
500
|
+
>
|
|
501
|
+
{v === "library" ? "Library" : `Bin${trash.length ? ` (${trash.length})` : ""}`}
|
|
502
|
+
</button>
|
|
503
|
+
))}
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
{view === "bin" ? (
|
|
507
|
+
<section className="space-y-4">
|
|
508
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border border-ink/10 bg-ice/40 px-3 py-2 text-[0.72rem] text-ink/70">
|
|
509
|
+
<span>
|
|
510
|
+
Deleted files are kept here. Restore puts them back at their original filename. Only a
|
|
511
|
+
superadmin can delete permanently.
|
|
512
|
+
</span>
|
|
513
|
+
{isSuper && trash.length > 0 ? (
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
onClick={onEmptyBin}
|
|
517
|
+
disabled={busyId !== null}
|
|
518
|
+
className="inline-flex items-center gap-1.5 border border-red-500 px-2.5 py-1 text-[0.65rem] uppercase tracking-widest text-red-600 hover:bg-red-500 hover:text-white disabled:opacity-50"
|
|
519
|
+
>
|
|
520
|
+
{busyId === "all" ? (
|
|
521
|
+
<Loader2 className="size-3 animate-spin" />
|
|
522
|
+
) : (
|
|
523
|
+
<Trash2 className="size-3" />
|
|
524
|
+
)}
|
|
525
|
+
Empty bin
|
|
526
|
+
</button>
|
|
527
|
+
) : null}
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{trashLoading ? (
|
|
531
|
+
<div className="text-sm text-ink/60">Loading…</div>
|
|
532
|
+
) : trash.length === 0 ? (
|
|
533
|
+
<div className="border border-dashed border-ink/20 p-16 text-center">
|
|
534
|
+
<p className="text-sm text-ink/60">The bin is empty.</p>
|
|
535
|
+
</div>
|
|
536
|
+
) : (
|
|
537
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
538
|
+
{trash.map((item) => {
|
|
539
|
+
const isImage = (item.contentType ?? "").startsWith("image/");
|
|
540
|
+
const isVideo = (item.contentType ?? "").startsWith("video/");
|
|
541
|
+
return (
|
|
542
|
+
<div key={item.id} className="border border-ink/10 bg-white flex flex-col">
|
|
543
|
+
<div className="aspect-square bg-ink/5 overflow-hidden flex items-center justify-center">
|
|
544
|
+
{isImage ? (
|
|
545
|
+
<img
|
|
546
|
+
src={item.url}
|
|
547
|
+
alt={item.originalPath}
|
|
548
|
+
className="w-full h-full object-cover opacity-70"
|
|
549
|
+
/>
|
|
550
|
+
) : isVideo ? (
|
|
551
|
+
<video
|
|
552
|
+
src={item.url}
|
|
553
|
+
className="w-full h-full object-cover opacity-70"
|
|
554
|
+
muted
|
|
555
|
+
/>
|
|
556
|
+
) : (
|
|
557
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
558
|
+
{item.contentType?.split("/")[1] ?? "file"}
|
|
559
|
+
</span>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
<div className="p-2.5 flex-1 flex flex-col gap-1.5">
|
|
563
|
+
<div className="text-xs text-ink truncate" title={item.originalPath}>
|
|
564
|
+
{item.originalPath}
|
|
565
|
+
</div>
|
|
566
|
+
<div className="text-[0.65rem] uppercase tracking-widest text-ink/45">
|
|
567
|
+
{humanBytes(item.size)} · {new Date(item.deletedAt).toLocaleDateString()}
|
|
568
|
+
</div>
|
|
569
|
+
<div
|
|
570
|
+
className="text-[0.65rem] text-ink/45 truncate"
|
|
571
|
+
title={item.deletedByEmail ?? ""}
|
|
572
|
+
>
|
|
573
|
+
{item.deletedByEmail ?? "Unknown user"}
|
|
574
|
+
</div>
|
|
575
|
+
<div className="mt-auto flex items-center gap-1.5 pt-2">
|
|
576
|
+
<button
|
|
577
|
+
onClick={() => onRestore(item)}
|
|
578
|
+
disabled={busyId !== null}
|
|
579
|
+
className="flex-1 inline-flex items-center justify-center gap-1 border border-ink/15 px-2 py-1 text-[0.7rem] hover:border-sea disabled:opacity-50"
|
|
580
|
+
title="Restore to library"
|
|
581
|
+
>
|
|
582
|
+
{busyId === item.id ? (
|
|
583
|
+
<Loader2 className="size-3 animate-spin" />
|
|
584
|
+
) : (
|
|
585
|
+
<RotateCcw className="size-3" />
|
|
586
|
+
)}
|
|
587
|
+
Restore
|
|
588
|
+
</button>
|
|
589
|
+
{isSuper ? (
|
|
590
|
+
<button
|
|
591
|
+
onClick={() => onPurge(item)}
|
|
592
|
+
disabled={busyId !== null}
|
|
593
|
+
className="inline-flex items-center justify-center border border-ink/15 p-1 text-red-600 hover:border-red-400 disabled:opacity-50"
|
|
594
|
+
title="Delete permanently"
|
|
595
|
+
>
|
|
596
|
+
<Trash2 className="size-3.5" />
|
|
597
|
+
</button>
|
|
598
|
+
) : null}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
})}
|
|
604
|
+
</div>
|
|
605
|
+
)}
|
|
606
|
+
</section>
|
|
607
|
+
) : (
|
|
608
|
+
<>
|
|
609
|
+
<div className="flex items-center gap-2 border border-ink/10 bg-ice/40 px-3 py-2 text-[0.72rem] text-ink/70">
|
|
610
|
+
<Zap className="size-3.5 text-sea" />
|
|
611
|
+
<span>
|
|
612
|
+
Auto-optimization: images become WebP at max 1920px width (~60–80% smaller). Videos
|
|
613
|
+
upload as-is with a poster frame extracted automatically.
|
|
614
|
+
</span>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border border-ink/10 bg-white px-3 py-2 text-[0.72rem] text-ink/70">
|
|
618
|
+
<span className="uppercase tracking-[0.18em] text-ink/45">
|
|
619
|
+
{month === "all"
|
|
620
|
+
? "Whole library"
|
|
621
|
+
: month === "unknown"
|
|
622
|
+
? "Unknown date"
|
|
623
|
+
: monthLabel(month)}
|
|
624
|
+
</span>
|
|
625
|
+
<span>
|
|
626
|
+
<strong className="text-ink">{imageCount}</strong> image{imageCount === 1 ? "" : "s"}
|
|
627
|
+
</span>
|
|
628
|
+
<span>
|
|
629
|
+
<strong className="text-ink">{videoCount}</strong> video{videoCount === 1 ? "" : "s"}
|
|
630
|
+
</span>
|
|
631
|
+
{otherCount > 0 ? (
|
|
632
|
+
<span>
|
|
633
|
+
<strong className="text-ink">{otherCount}</strong> other file
|
|
634
|
+
{otherCount === 1 ? "" : "s"}
|
|
635
|
+
</span>
|
|
636
|
+
) : null}
|
|
637
|
+
<span className="ml-auto text-ink/50">
|
|
638
|
+
Showing {Math.min(shown.length, filtered.length)} of {filtered.length}
|
|
639
|
+
</span>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
{uploading ? (
|
|
643
|
+
<div className="space-y-1.5 border border-ink/10 bg-white px-3 py-2">
|
|
644
|
+
<div className="flex items-center gap-1.5 text-[0.72rem] text-ink/70">
|
|
645
|
+
<Loader2 className="size-3 animate-spin" />
|
|
646
|
+
<span>{progress ?? "Preparing…"}</span>
|
|
647
|
+
<span className="ml-auto tabular-nums text-ink/50">{percent}%</span>
|
|
648
|
+
</div>
|
|
649
|
+
<div className="h-1.5 w-full overflow-hidden bg-ink/10">
|
|
650
|
+
<div
|
|
651
|
+
className="h-full bg-sea transition-[width] duration-200"
|
|
652
|
+
style={{ width: `${percent}%` }}
|
|
653
|
+
/>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
) : null}
|
|
657
|
+
|
|
658
|
+
{!isLoading && filtered.length > 0 ? (
|
|
659
|
+
<div className="sticky top-0 z-30 flex flex-wrap items-center gap-2 border border-ink/10 bg-white px-3 py-2">
|
|
660
|
+
<label className="inline-flex cursor-pointer items-center gap-2 text-xs text-ink">
|
|
661
|
+
<input
|
|
662
|
+
type="checkbox"
|
|
663
|
+
checked={allShownSelected}
|
|
664
|
+
onChange={toggleAllShown}
|
|
665
|
+
className="size-4 accent-[color:var(--color-sea,#2ac4ea)]"
|
|
666
|
+
/>
|
|
667
|
+
Select all shown ({shown.length})
|
|
668
|
+
</label>
|
|
669
|
+
{selected.length > 0 ? (
|
|
670
|
+
<>
|
|
671
|
+
<span className="text-xs text-ink/60">
|
|
672
|
+
{selected.length} selected
|
|
673
|
+
{selectedUses > 0 ? ` · ${selectedUses} use(s) on the site` : ""}
|
|
674
|
+
</span>
|
|
675
|
+
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
|
676
|
+
<button
|
|
677
|
+
type="button"
|
|
678
|
+
onClick={bulkCopyUrls}
|
|
679
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-2.5 py-1 text-[0.7rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
680
|
+
>
|
|
681
|
+
<Copy className="size-3" /> Copy URLs
|
|
682
|
+
</button>
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
onClick={bulkDownload}
|
|
686
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-2.5 py-1 text-[0.7rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
687
|
+
>
|
|
688
|
+
<Download className="size-3" /> Download
|
|
689
|
+
</button>
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
onClick={() => setConfirmBulk("delete")}
|
|
693
|
+
className="inline-flex items-center gap-1.5 border border-red-300 px-2.5 py-1 text-[0.7rem] uppercase tracking-[0.18em] text-red-600 hover:border-red-500"
|
|
694
|
+
>
|
|
695
|
+
<Trash2 className="size-3" /> Move to bin
|
|
696
|
+
</button>
|
|
697
|
+
<button
|
|
698
|
+
type="button"
|
|
699
|
+
onClick={() => setSelected([])}
|
|
700
|
+
className="inline-flex items-center gap-1 border border-ink/15 px-2 py-1 text-[0.7rem] uppercase tracking-[0.18em] text-ink/60 hover:border-ink"
|
|
701
|
+
>
|
|
702
|
+
<X className="size-3" /> Clear
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
</>
|
|
706
|
+
) : (
|
|
707
|
+
<span className="ml-auto text-[0.7rem] text-ink/45">
|
|
708
|
+
Tick files to delete, download or copy several at once.
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
711
|
+
</div>
|
|
712
|
+
) : null}
|
|
713
|
+
|
|
714
|
+
{isLoading ? (
|
|
715
|
+
<div className="text-sm text-ink/60">Loading…</div>
|
|
716
|
+
) : filtered.length === 0 ? (
|
|
717
|
+
<div className="border border-dashed border-ink/20 p-16 text-center">
|
|
718
|
+
<p className="text-sm text-ink/60">
|
|
719
|
+
{items.length === 0 ? "No files yet. Upload your first asset." : "No matches."}
|
|
720
|
+
</p>
|
|
721
|
+
</div>
|
|
722
|
+
) : (
|
|
723
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
724
|
+
{shown.map((item) => {
|
|
725
|
+
const isImage = (item.contentType ?? "").startsWith("image/");
|
|
726
|
+
const isVideo = (item.contentType ?? "").startsWith("video/");
|
|
727
|
+
const usages = usageMap[item.path] ?? [];
|
|
728
|
+
const inUse = usages.length > 0;
|
|
729
|
+
const isSelected = selected.includes(item.path);
|
|
730
|
+
return (
|
|
731
|
+
<div
|
|
732
|
+
key={item.path}
|
|
733
|
+
className={[
|
|
734
|
+
"group border bg-white flex flex-col relative",
|
|
735
|
+
isSelected ? "border-sea ring-2 ring-sea/40" : "border-ink/10",
|
|
736
|
+
].join(" ")}
|
|
737
|
+
>
|
|
738
|
+
<label
|
|
739
|
+
className="absolute top-1.5 right-1.5 z-10 inline-flex cursor-pointer items-center justify-center border border-ink/15 bg-white/90 p-1"
|
|
740
|
+
title="Select this file"
|
|
741
|
+
>
|
|
742
|
+
<input
|
|
743
|
+
type="checkbox"
|
|
744
|
+
checked={isSelected}
|
|
745
|
+
onChange={() => toggleOne(item.path)}
|
|
746
|
+
className="size-3.5 accent-[color:var(--color-sea,#2ac4ea)]"
|
|
747
|
+
/>
|
|
748
|
+
</label>
|
|
749
|
+
|
|
750
|
+
<div className="aspect-square bg-ink/5 overflow-hidden flex items-center justify-center">
|
|
751
|
+
{isImage ? (
|
|
752
|
+
<FocalImage
|
|
753
|
+
src={bustUrl(item.url)}
|
|
754
|
+
responsive={false}
|
|
755
|
+
alt={item.name}
|
|
756
|
+
className="w-full h-full object-cover"
|
|
757
|
+
/>
|
|
758
|
+
) : isVideo ? (
|
|
759
|
+
<video
|
|
760
|
+
src={bustUrl(item.url)}
|
|
761
|
+
className="w-full h-full object-cover"
|
|
762
|
+
muted
|
|
763
|
+
/>
|
|
764
|
+
) : (
|
|
765
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
766
|
+
{item.contentType?.split("/")[1] ?? "file"}
|
|
767
|
+
</span>
|
|
768
|
+
)}
|
|
769
|
+
<button
|
|
770
|
+
type="button"
|
|
771
|
+
onClick={() => inUse && setUsageFor({ title: item.name, usages })}
|
|
772
|
+
disabled={!inUse}
|
|
773
|
+
title={
|
|
774
|
+
inUse
|
|
775
|
+
? `In use in ${usages.length} place${usages.length > 1 ? "s" : ""} — click for details`
|
|
776
|
+
: "Not referenced anywhere"
|
|
777
|
+
}
|
|
778
|
+
className={[
|
|
779
|
+
"absolute top-1.5 left-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 text-[0.6rem] uppercase tracking-widest border",
|
|
780
|
+
inUse
|
|
781
|
+
? "bg-sea text-white border-sea cursor-pointer hover:bg-ink hover:border-ink"
|
|
782
|
+
: "bg-white/80 text-ink/40 border-ink/15 cursor-default",
|
|
783
|
+
].join(" ")}
|
|
784
|
+
>
|
|
785
|
+
<Link2 className="size-3" />
|
|
786
|
+
{inUse ? `${usages.length} use${usages.length > 1 ? "s" : ""}` : "Unused"}
|
|
787
|
+
</button>
|
|
788
|
+
</div>
|
|
789
|
+
<div className="p-2.5 flex-1 flex flex-col gap-1.5">
|
|
790
|
+
<div className="text-xs text-ink truncate" title={item.name}>
|
|
791
|
+
{item.name}
|
|
792
|
+
</div>
|
|
793
|
+
<div className="text-[0.65rem] uppercase tracking-widest text-ink/45">
|
|
794
|
+
{humanBytes(item.size)} · {isoDate(item) ?? "Unknown date"}
|
|
795
|
+
</div>
|
|
796
|
+
<div
|
|
797
|
+
className="text-[0.65rem] text-ink/45 truncate"
|
|
798
|
+
title={item.uploadedBy ?? "Uploader unknown"}
|
|
799
|
+
>
|
|
800
|
+
{item.uploadedBy ?? "Uploader unknown"}
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
<div className="mt-auto flex items-center gap-1.5 pt-2">
|
|
804
|
+
<button
|
|
805
|
+
onClick={() => onCopy(item.url)}
|
|
806
|
+
className="flex-1 inline-flex items-center justify-center gap-1 border border-ink/15 px-2 py-1 text-[0.7rem] hover:border-sea"
|
|
807
|
+
title="Copy signed URL"
|
|
808
|
+
>
|
|
809
|
+
<Copy className="size-3" /> URL
|
|
810
|
+
</button>
|
|
811
|
+
{isImage ? (
|
|
812
|
+
<button
|
|
813
|
+
onClick={() => setMetaFor(item.url)}
|
|
814
|
+
className={[
|
|
815
|
+
"inline-flex items-center justify-center border p-1 hover:border-sea",
|
|
816
|
+
hasMediaMeta(lookupMediaMeta(metaMap, item.url))
|
|
817
|
+
? "border-sea text-sea"
|
|
818
|
+
: "border-ink/15 text-ink/70",
|
|
819
|
+
].join(" ")}
|
|
820
|
+
title="Edit SEO metadata (alt, OG title/description)"
|
|
821
|
+
>
|
|
822
|
+
<Tags className="size-3.5" />
|
|
823
|
+
</button>
|
|
824
|
+
) : null}
|
|
825
|
+
{isImage ? (
|
|
826
|
+
<button
|
|
827
|
+
onClick={() => setFocusFor(item.url)}
|
|
828
|
+
className="inline-flex items-center justify-center border border-ink/15 p-1 text-ink/70 hover:border-sea"
|
|
829
|
+
title="Set focus point (crop)"
|
|
830
|
+
>
|
|
831
|
+
<Crosshair className="size-3.5" />
|
|
832
|
+
</button>
|
|
833
|
+
) : null}
|
|
834
|
+
<button
|
|
835
|
+
onClick={() => setReplaceFor(item)}
|
|
836
|
+
className="inline-flex items-center justify-center border border-ink/15 p-1 text-ink/70 hover:border-sea"
|
|
837
|
+
title="Replace this file everywhere it is used on the site"
|
|
838
|
+
>
|
|
839
|
+
<Replace className="size-3.5" />
|
|
840
|
+
</button>
|
|
841
|
+
<button
|
|
842
|
+
onClick={() => setConfirmOne(item)}
|
|
843
|
+
className="inline-flex items-center justify-center border border-ink/15 p-1 text-red-600 hover:border-red-400"
|
|
844
|
+
title="Delete"
|
|
845
|
+
>
|
|
846
|
+
<Trash2 className="size-3.5" />
|
|
847
|
+
</button>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
})}
|
|
853
|
+
</div>
|
|
854
|
+
)}
|
|
855
|
+
|
|
856
|
+
{filtered.length > shown.length ? (
|
|
857
|
+
<div className="flex justify-center">
|
|
858
|
+
<button
|
|
859
|
+
type="button"
|
|
860
|
+
onClick={() => setVisible((n) => n + PAGE_SIZE)}
|
|
861
|
+
className="border border-ink px-5 py-2 text-xs uppercase tracking-[0.22em] text-ink hover:bg-ink hover:text-white"
|
|
862
|
+
>
|
|
863
|
+
Load more ({filtered.length - shown.length} left)
|
|
864
|
+
</button>
|
|
865
|
+
</div>
|
|
866
|
+
) : null}
|
|
867
|
+
|
|
868
|
+
{/* External references section — media used on the site but not in the library. */}
|
|
869
|
+
{externals.length > 0 && (
|
|
870
|
+
<section className="border border-ink/10 bg-white">
|
|
871
|
+
<header className="px-4 py-3 border-b border-ink/10 flex items-center justify-between">
|
|
872
|
+
<div>
|
|
873
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
874
|
+
External media
|
|
875
|
+
</div>
|
|
876
|
+
<div className="text-sm text-ink/70 mt-0.5">
|
|
877
|
+
{externals.length} URL{externals.length > 1 ? "s" : ""} referenced by pages but
|
|
878
|
+
not in the library.
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
<button
|
|
882
|
+
type="button"
|
|
883
|
+
disabled={importingUrl !== null}
|
|
884
|
+
onClick={async () => {
|
|
885
|
+
for (const ext of externals) {
|
|
886
|
+
setImportingUrl(ext.url);
|
|
887
|
+
try {
|
|
888
|
+
await importAndRefresh(ext.url, false);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
toast.error(
|
|
891
|
+
`Failed: ${ext.url}: ${e instanceof Error ? e.message : String(e)}`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
setImportingUrl(null);
|
|
896
|
+
toast.success("Finished importing external media");
|
|
897
|
+
await refreshAll();
|
|
898
|
+
}}
|
|
899
|
+
className="inline-flex items-center gap-2 border border-ink px-3 py-1.5 text-xs uppercase tracking-[0.22em] hover:bg-ink hover:text-white disabled:opacity-50"
|
|
900
|
+
>
|
|
901
|
+
{importingUrl ? (
|
|
902
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
903
|
+
) : (
|
|
904
|
+
<Download className="size-3.5" />
|
|
905
|
+
)}
|
|
906
|
+
Import all to library
|
|
907
|
+
</button>
|
|
908
|
+
</header>
|
|
909
|
+
<ul className="divide-y divide-ink/10">
|
|
910
|
+
{externals.slice(0, 100).map((ext) => (
|
|
911
|
+
<li key={ext.url} className="px-4 py-3 flex items-center gap-4">
|
|
912
|
+
<div className="min-w-0 flex-1">
|
|
913
|
+
<div className="text-xs text-ink truncate" title={ext.url}>
|
|
914
|
+
{ext.url}
|
|
915
|
+
</div>
|
|
916
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
917
|
+
{ext.usages.slice(0, 6).map((u, i) => (
|
|
918
|
+
<Link
|
|
919
|
+
key={i}
|
|
920
|
+
to={u.editPath}
|
|
921
|
+
className="text-[0.6rem] uppercase tracking-widest border border-ink/15 px-1.5 py-0.5 text-ink/70 hover:border-sea hover:text-ink"
|
|
922
|
+
>
|
|
923
|
+
{u.label}
|
|
924
|
+
</Link>
|
|
925
|
+
))}
|
|
926
|
+
{ext.usages.length > 6 && (
|
|
927
|
+
<span className="text-[0.6rem] text-ink/50">
|
|
928
|
+
+{ext.usages.length - 6} more
|
|
929
|
+
</span>
|
|
930
|
+
)}
|
|
931
|
+
</div>
|
|
932
|
+
</div>
|
|
933
|
+
<button
|
|
934
|
+
type="button"
|
|
935
|
+
disabled={importingUrl === ext.url}
|
|
936
|
+
onClick={async () => {
|
|
937
|
+
setImportingUrl(ext.url);
|
|
938
|
+
try {
|
|
939
|
+
await importAndRefresh(ext.url, true);
|
|
940
|
+
await refreshAll();
|
|
941
|
+
} catch (e) {
|
|
942
|
+
toast.error(e instanceof Error ? e.message : "Import failed");
|
|
943
|
+
} finally {
|
|
944
|
+
setImportingUrl(null);
|
|
945
|
+
}
|
|
946
|
+
}}
|
|
947
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-2 py-1 text-[0.7rem] hover:border-sea disabled:opacity-50"
|
|
948
|
+
>
|
|
949
|
+
{importingUrl === ext.url ? (
|
|
950
|
+
<Loader2 className="size-3 animate-spin" />
|
|
951
|
+
) : (
|
|
952
|
+
<Download className="size-3" />
|
|
953
|
+
)}
|
|
954
|
+
Import
|
|
955
|
+
</button>
|
|
956
|
+
</li>
|
|
957
|
+
))}
|
|
958
|
+
</ul>
|
|
959
|
+
</section>
|
|
960
|
+
)}
|
|
961
|
+
</>
|
|
962
|
+
)}
|
|
963
|
+
|
|
964
|
+
{metaFor && (
|
|
965
|
+
<MediaMetaDialog
|
|
966
|
+
src={metaFor}
|
|
967
|
+
title={decodeURIComponent(metaFor.split("/").pop() ?? "")}
|
|
968
|
+
onClose={() => setMetaFor(null)}
|
|
969
|
+
/>
|
|
970
|
+
)}
|
|
971
|
+
|
|
972
|
+
{focusFor && (
|
|
973
|
+
<FocusPicker
|
|
974
|
+
src={focusFor}
|
|
975
|
+
modal
|
|
976
|
+
title={decodeURIComponent(focusFor.split("/").pop() ?? "")}
|
|
977
|
+
onClose={() => setFocusFor(null)}
|
|
978
|
+
/>
|
|
979
|
+
)}
|
|
980
|
+
|
|
981
|
+
{confirmOne ? (
|
|
982
|
+
<ConfirmDialog
|
|
983
|
+
title="Move file to the bin?"
|
|
984
|
+
description={
|
|
985
|
+
(usageMap[confirmOne.path]?.length ?? 0) > 0
|
|
986
|
+
? `"${confirmOne.name}" is still used in ${usageMap[confirmOne.path]!.length} place(s) — it will disappear from the site. You can restore it from the bin.`
|
|
987
|
+
: `"${confirmOne.name}" will be moved to the bin. You can restore it later.`
|
|
988
|
+
}
|
|
989
|
+
confirmLabel="Move to bin"
|
|
990
|
+
danger
|
|
991
|
+
onCancel={() => setConfirmOne(null)}
|
|
992
|
+
onConfirm={async () => {
|
|
993
|
+
const item = confirmOne;
|
|
994
|
+
setConfirmOne(null);
|
|
995
|
+
await doDelete(item);
|
|
996
|
+
}}
|
|
997
|
+
/>
|
|
998
|
+
) : null}
|
|
999
|
+
|
|
1000
|
+
{replaceFor ? (
|
|
1001
|
+
<MediaReplaceDialog
|
|
1002
|
+
source={replaceFor}
|
|
1003
|
+
items={items}
|
|
1004
|
+
usageCount={usageMap[replaceFor.path]?.length ?? 0}
|
|
1005
|
+
onClose={() => setReplaceFor(null)}
|
|
1006
|
+
onDone={refreshAll}
|
|
1007
|
+
/>
|
|
1008
|
+
) : null}
|
|
1009
|
+
|
|
1010
|
+
{confirmBulk === "delete" ? (
|
|
1011
|
+
<ConfirmDialog
|
|
1012
|
+
title={`Move ${selected.length} file(s) to the bin?`}
|
|
1013
|
+
description={
|
|
1014
|
+
selectedUses > 0
|
|
1015
|
+
? `These files are used in ${selectedUses} place(s) on the site and will disappear there. You can restore them from the bin.`
|
|
1016
|
+
: "The selected files will be moved to the bin. You can restore them later."
|
|
1017
|
+
}
|
|
1018
|
+
details={selectedItems.map((i) => i.name)}
|
|
1019
|
+
confirmLabel="Move to bin"
|
|
1020
|
+
danger
|
|
1021
|
+
busy={bulkBusy}
|
|
1022
|
+
onCancel={() => setConfirmBulk(null)}
|
|
1023
|
+
onConfirm={bulkDelete}
|
|
1024
|
+
/>
|
|
1025
|
+
) : null}
|
|
1026
|
+
|
|
1027
|
+
{/* Usage details dialog */}
|
|
1028
|
+
{usageFor && (
|
|
1029
|
+
<div
|
|
1030
|
+
className="fixed inset-0 z-50 bg-ink/60 grid place-items-center p-6"
|
|
1031
|
+
onClick={() => setUsageFor(null)}
|
|
1032
|
+
>
|
|
1033
|
+
<div
|
|
1034
|
+
onClick={(e) => e.stopPropagation()}
|
|
1035
|
+
className="bg-white max-w-lg w-full max-h-[80vh] overflow-auto"
|
|
1036
|
+
>
|
|
1037
|
+
<header className="px-5 py-4 border-b border-ink/10 flex items-center justify-between">
|
|
1038
|
+
<div>
|
|
1039
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
1040
|
+
Used in {usageFor.usages.length} place{usageFor.usages.length > 1 ? "s" : ""}
|
|
1041
|
+
</div>
|
|
1042
|
+
<div className="mt-0.5 text-sm text-ink truncate max-w-md" title={usageFor.title}>
|
|
1043
|
+
{usageFor.title}
|
|
1044
|
+
</div>
|
|
1045
|
+
</div>
|
|
1046
|
+
<button
|
|
1047
|
+
type="button"
|
|
1048
|
+
onClick={() => setUsageFor(null)}
|
|
1049
|
+
className="p-1 text-ink/60 hover:text-ink"
|
|
1050
|
+
>
|
|
1051
|
+
<X className="size-4" />
|
|
1052
|
+
</button>
|
|
1053
|
+
</header>
|
|
1054
|
+
<ul className="divide-y divide-ink/10">
|
|
1055
|
+
{usageFor.usages.map((u, i) => (
|
|
1056
|
+
<li key={i} className="px-5 py-3 flex items-center gap-3">
|
|
1057
|
+
<div className="min-w-0 flex-1">
|
|
1058
|
+
<div className="text-sm text-ink">{u.label}</div>
|
|
1059
|
+
<div className="mt-0.5 text-[0.7rem] text-ink/55 font-mono truncate">
|
|
1060
|
+
{u.jsonPath}
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
1064
|
+
{u.publicPath && (
|
|
1065
|
+
<a
|
|
1066
|
+
href={u.publicPath}
|
|
1067
|
+
target="_blank"
|
|
1068
|
+
rel="noreferrer"
|
|
1069
|
+
className="text-[0.65rem] uppercase tracking-widest border border-ink/15 px-2 py-1 hover:border-sea"
|
|
1070
|
+
>
|
|
1071
|
+
View
|
|
1072
|
+
</a>
|
|
1073
|
+
)}
|
|
1074
|
+
<Link
|
|
1075
|
+
to={u.editPath}
|
|
1076
|
+
onClick={() => setUsageFor(null)}
|
|
1077
|
+
className="text-[0.65rem] uppercase tracking-widest border border-ink px-2 py-1 hover:bg-ink hover:text-white"
|
|
1078
|
+
>
|
|
1079
|
+
Edit
|
|
1080
|
+
</Link>
|
|
1081
|
+
</div>
|
|
1082
|
+
</li>
|
|
1083
|
+
))}
|
|
1084
|
+
</ul>
|
|
1085
|
+
</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
)}
|
|
1088
|
+
</div>
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Shapes the shared Media Library screen needs from the host. */
|
|
2
|
+
|
|
3
|
+
export type MediaLibraryItem = {
|
|
4
|
+
path: string;
|
|
5
|
+
name: string;
|
|
6
|
+
url: string;
|
|
7
|
+
size: number;
|
|
8
|
+
contentType?: string | null;
|
|
9
|
+
uploadedAt?: string | null;
|
|
10
|
+
updatedAt?: string | null;
|
|
11
|
+
uploadedBy?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MediaTrashItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
originalPath: string;
|
|
17
|
+
url: string;
|
|
18
|
+
size: number;
|
|
19
|
+
contentType?: string | null;
|
|
20
|
+
deletedAt: string;
|
|
21
|
+
deletedByEmail?: string | null;
|
|
22
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imperative poster-frame picker for video uploads.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a small dialog on demand so any upload surface (Media Library, block
|
|
5
|
+
* media picker) can ask the editor which frame to use as the video poster.
|
|
6
|
+
* Resolves with the chosen time in seconds, or `null` to keep the automatic
|
|
7
|
+
* frame (~10% into the clip).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { createRoot } from "react-dom/client";
|
|
11
|
+
|
|
12
|
+
function formatTime(s: number) {
|
|
13
|
+
if (!Number.isFinite(s)) return "0:00";
|
|
14
|
+
const m = Math.floor(s / 60);
|
|
15
|
+
const sec = Math.floor(s % 60);
|
|
16
|
+
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function PosterDialog({ file, onDone }: { file: File; onDone: (seconds: number | null) => void }) {
|
|
20
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
21
|
+
const [url] = useState(() => URL.createObjectURL(file));
|
|
22
|
+
const [duration, setDuration] = useState(0);
|
|
23
|
+
const [time, setTime] = useState(0);
|
|
24
|
+
|
|
25
|
+
useEffect(() => () => URL.revokeObjectURL(url), [url]);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const onKey = (e: KeyboardEvent) => {
|
|
29
|
+
if (e.key === "Escape") onDone(null);
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener("keydown", onKey);
|
|
32
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
33
|
+
}, [onDone]);
|
|
34
|
+
|
|
35
|
+
const seek = (v: number) => {
|
|
36
|
+
setTime(v);
|
|
37
|
+
if (videoRef.current) videoRef.current.currentTime = v;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-ink/70 p-4">
|
|
42
|
+
<div className="w-full max-w-2xl border border-ink/15 bg-white shadow-xl">
|
|
43
|
+
<header className="flex items-center justify-between border-b border-ink/10 px-4 py-3">
|
|
44
|
+
<div>
|
|
45
|
+
<h2 className="text-sm font-medium text-ink">Choose poster frame</h2>
|
|
46
|
+
<p className="text-[0.7rem] text-ink/55">{file.name}</p>
|
|
47
|
+
</div>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => onDone(null)}
|
|
51
|
+
className="text-[0.7rem] uppercase tracking-[0.18em] text-ink/50 hover:text-ink"
|
|
52
|
+
>
|
|
53
|
+
Cancel
|
|
54
|
+
</button>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<div className="space-y-3 p-4">
|
|
58
|
+
<video
|
|
59
|
+
ref={videoRef}
|
|
60
|
+
src={url}
|
|
61
|
+
muted
|
|
62
|
+
playsInline
|
|
63
|
+
preload="auto"
|
|
64
|
+
className="max-h-[50vh] w-full bg-ink/5 object-contain"
|
|
65
|
+
onLoadedMetadata={(e) => {
|
|
66
|
+
const v = e.currentTarget;
|
|
67
|
+
const d = Number.isFinite(v.duration) ? v.duration : 0;
|
|
68
|
+
setDuration(d);
|
|
69
|
+
const start = Math.min(d, d * 0.1);
|
|
70
|
+
setTime(start);
|
|
71
|
+
v.currentTime = start;
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
<input
|
|
75
|
+
type="range"
|
|
76
|
+
min={0}
|
|
77
|
+
max={Math.max(0.1, duration)}
|
|
78
|
+
step={0.05}
|
|
79
|
+
value={time}
|
|
80
|
+
onChange={(e) => seek(Number(e.target.value))}
|
|
81
|
+
className="w-full accent-sea"
|
|
82
|
+
/>
|
|
83
|
+
<div className="flex items-center justify-between text-[0.72rem] text-ink/60">
|
|
84
|
+
<span>
|
|
85
|
+
{formatTime(time)} / {formatTime(duration)}
|
|
86
|
+
</span>
|
|
87
|
+
<span>Drag the slider to the frame you want as the poster image.</span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<footer className="flex justify-end gap-2 border-t border-ink/10 px-4 py-3">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => onDone(null)}
|
|
95
|
+
className="border border-ink/15 px-3 py-1.5 text-sm hover:border-ink/40"
|
|
96
|
+
>
|
|
97
|
+
Use automatic frame
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => onDone(time)}
|
|
102
|
+
className="bg-ink px-4 py-1.5 text-sm text-paper hover:bg-sea"
|
|
103
|
+
>
|
|
104
|
+
Use this frame
|
|
105
|
+
</button>
|
|
106
|
+
</footer>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Ask the editor to pick a poster frame. Returns seconds, or null for auto. */
|
|
113
|
+
export function pickVideoPoster(file: File): Promise<number | null> {
|
|
114
|
+
if (typeof document === "undefined") return Promise.resolve(null);
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const host = document.createElement("div");
|
|
117
|
+
document.body.appendChild(host);
|
|
118
|
+
const root = createRoot(host);
|
|
119
|
+
const done = (seconds: number | null) => {
|
|
120
|
+
resolve(seconds);
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
root.unmount();
|
|
123
|
+
host.remove();
|
|
124
|
+
}, 0);
|
|
125
|
+
};
|
|
126
|
+
root.render(<PosterDialog file={file} onDone={done} />);
|
|
127
|
+
});
|
|
128
|
+
}
|