@bygghammar/cms-platform 0.16.0 → 0.18.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.
@@ -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
+ }