@bygghammar/cms-platform 0.6.0 → 0.8.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,158 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Loader2, Search } from "lucide-react";
3
+ import { toast } from "sonner";
4
+
5
+ /** Minimal library item shape this dialog needs. */
6
+ export type MediaItem = {
7
+ path: string;
8
+ name: string;
9
+ contentType?: string | null;
10
+ url?: string;
11
+ };
12
+
13
+ /**
14
+ * Pick a replacement file for one library asset and swap it everywhere it is
15
+ * referenced on the public site. Backoffice UI is English.
16
+ */
17
+ export function MediaReplaceDialog({
18
+ source,
19
+ items,
20
+ usageCount,
21
+ onClose,
22
+ onDone,
23
+ replaceMedia,
24
+ }: {
25
+ source: MediaItem;
26
+ items: MediaItem[];
27
+ usageCount: number;
28
+ onClose: () => void;
29
+ onDone: () => Promise<void> | void;
30
+ /** Host-provided sitewide replace. */
31
+ replaceMedia: (input: {
32
+ data: { fromPath: string; toPath: string };
33
+ }) => Promise<{ rewritten: number }>;
34
+ }) {
35
+ const replaceFn = replaceMedia;
36
+ const [q, setQ] = useState("");
37
+ const [target, setTarget] = useState<MediaItem | null>(null);
38
+ const [busy, setBusy] = useState(false);
39
+
40
+ const candidates = useMemo(() => {
41
+ const needle = q.trim().toLowerCase();
42
+ return items
43
+ .filter((i) => i.path !== source.path)
44
+ .filter((i) => (needle ? i.name.toLowerCase().includes(needle) : true))
45
+ .slice(0, 60);
46
+ }, [items, q, source.path]);
47
+
48
+ const run = async () => {
49
+ if (!target) return;
50
+ setBusy(true);
51
+ try {
52
+ const res = await replaceFn({ data: { fromPath: source.path, toPath: target.path } });
53
+ toast.success(
54
+ res.rewritten > 0
55
+ ? `Replaced in ${res.rewritten} reference(s) across the site`
56
+ : "No references needed changing",
57
+ );
58
+ await onDone();
59
+ onClose();
60
+ } catch (e: any) {
61
+ toast.error(e?.message ?? "Replace failed");
62
+ } finally {
63
+ setBusy(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <div
69
+ className="fixed inset-0 z-[60] grid place-items-center bg-black/50 p-4"
70
+ onClick={() => !busy && onClose()}
71
+ >
72
+ <div
73
+ className="flex max-h-[85vh] w-full max-w-2xl flex-col border border-ink/15 bg-white"
74
+ onClick={(e) => e.stopPropagation()}
75
+ >
76
+ <header className="border-b border-ink/10 px-4 py-3">
77
+ <h2 className="text-sm font-medium text-ink">Replace file everywhere</h2>
78
+ <p className="mt-1 text-xs text-ink/60">
79
+ Every place using <span className="text-ink">{source.name}</span> ({usageCount} use
80
+ {usageCount === 1 ? "" : "s"}) will point at the file you pick. The original file stays
81
+ in the library.
82
+ </p>
83
+ </header>
84
+
85
+ <div className="border-b border-ink/10 px-4 py-2">
86
+ <div className="flex items-center gap-2 border border-ink/15 px-2 py-1.5">
87
+ <Search className="size-3.5 text-ink/40" />
88
+ <input
89
+ value={q}
90
+ onChange={(e) => setQ(e.target.value)}
91
+ placeholder="Search replacement file…"
92
+ className="w-full bg-transparent text-xs outline-none"
93
+ />
94
+ </div>
95
+ </div>
96
+
97
+ <div className="grid flex-1 grid-cols-3 gap-3 overflow-auto p-4 sm:grid-cols-4">
98
+ {candidates.map((item) => {
99
+ const isImage = (item.contentType ?? "").startsWith("image/");
100
+ const active = target?.path === item.path;
101
+ return (
102
+ <button
103
+ key={item.path}
104
+ type="button"
105
+ onClick={() => setTarget(item)}
106
+ className={[
107
+ "border text-left",
108
+ active ? "border-sea ring-2 ring-sea/40" : "border-ink/10 hover:border-ink/30",
109
+ ].join(" ")}
110
+ >
111
+ <div className="flex aspect-square items-center justify-center overflow-hidden bg-ink/5">
112
+ {isImage ? (
113
+ <img src={item.url} alt={item.name} className="h-full w-full object-cover" />
114
+ ) : (
115
+ <span className="text-[0.6rem] uppercase tracking-widest text-ink/50">
116
+ {item.contentType?.split("/")[1] ?? "file"}
117
+ </span>
118
+ )}
119
+ </div>
120
+ <div className="truncate p-1.5 text-[0.68rem] text-ink" title={item.name}>
121
+ {item.name}
122
+ </div>
123
+ </button>
124
+ );
125
+ })}
126
+ {candidates.length === 0 ? (
127
+ <p className="col-span-full text-xs text-ink/50">No matching files.</p>
128
+ ) : null}
129
+ </div>
130
+
131
+ <footer className="flex items-center justify-between gap-2 border-t border-ink/10 px-4 py-3">
132
+ <span className="truncate text-[0.7rem] text-ink/60">
133
+ {target ? `Replacement: ${target.name}` : "Select a replacement file"}
134
+ </span>
135
+ <div className="flex gap-2">
136
+ <button
137
+ type="button"
138
+ disabled={busy}
139
+ onClick={onClose}
140
+ className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-ink disabled:opacity-50"
141
+ >
142
+ Cancel
143
+ </button>
144
+ <button
145
+ type="button"
146
+ disabled={!target || busy}
147
+ onClick={run}
148
+ className="inline-flex items-center gap-1.5 bg-ink px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-white hover:bg-sea disabled:opacity-50"
149
+ >
150
+ {busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
151
+ Replace everywhere
152
+ </button>
153
+ </div>
154
+ </footer>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,226 @@
1
+ import { useRef, useState } from "react";
2
+ import { Loader2, RefreshCw } from "lucide-react";
3
+ import { toast } from "sonner";
4
+
5
+ import { optimizeForUpload } from "../../media/optimize";
6
+ import { cleanFileName, splitExt } from "../../media/filename";
7
+
8
+ type RunSummary = {
9
+ overwritten: string[];
10
+ skipped: string[];
11
+ failed: string[];
12
+ };
13
+
14
+ /**
15
+ * Superadmin-only bulk re-upload. Re-selects the original source files and
16
+ * regenerates every responsive variant at the current quality settings,
17
+ * writing over the existing storage paths so filenames — and therefore every
18
+ * reference on the site — stay exactly the same.
19
+ */
20
+ export function ReuploadOverwrite({
21
+ onDone,
22
+ prepareOverwrite,
23
+ uploadBundle: uploadBundleFn,
24
+ logOverwrite,
25
+ }: {
26
+ onDone: () => Promise<void> | void;
27
+ /** Host-provided lookup of which base names already exist in the library. */
28
+ prepareOverwrite: (input: {
29
+ data: { bases: string[] };
30
+ }) => Promise<Record<string, { exists: boolean } | undefined>>;
31
+ /** Host-provided upload of one optimized bundle. */
32
+ uploadBundle: (input: {
33
+ data: {
34
+ files: { name: string; contentType: string; bytes: number[] }[];
35
+ upsert: boolean;
36
+ };
37
+ }) => Promise<unknown>;
38
+ /** Host-provided audit logger for one overwrite run. */
39
+ logOverwrite: (input: {
40
+ data: { paths: string[]; skipped: string[]; failed: string[] };
41
+ }) => Promise<unknown>;
42
+ }) {
43
+ const input = useRef<HTMLInputElement>(null);
44
+ const prepare = prepareOverwrite;
45
+ const logRun = logOverwrite;
46
+ const uploadBundle = uploadBundleFn;
47
+ const [busy, setBusy] = useState(false);
48
+ const [progress, setProgress] = useState<string | null>(null);
49
+ const [percent, setPercent] = useState(0);
50
+ const [addUnmatched, setAddUnmatched] = useState(false);
51
+ const [summary, setSummary] = useState<RunSummary | null>(null);
52
+
53
+ const run = async (files: FileList | null) => {
54
+ const arr = files ? Array.from(files) : [];
55
+ if (!arr.length) return;
56
+ if (
57
+ !confirm(
58
+ `Re-upload ${arr.length} file(s) and overwrite the matching files in the library?\n\n` +
59
+ `Filenames stay the same, so every page keeps working — the images are simply replaced with the new, higher quality versions.`,
60
+ )
61
+ )
62
+ return;
63
+
64
+ setBusy(true);
65
+ setSummary(null);
66
+ const overwritten: string[] = [];
67
+ const skipped: string[] = [];
68
+ const failed: string[] = [];
69
+
70
+ try {
71
+ const bases = arr.map((f) => splitExt(cleanFileName(f.name)).base);
72
+ setProgress("Checking library…");
73
+ const existing = await prepare({ data: { bases: Array.from(new Set(bases)) } });
74
+
75
+ for (let idx = 0; idx < arr.length; idx++) {
76
+ const file = arr[idx];
77
+ const base = bases[idx];
78
+ const match = existing[base];
79
+ if (!match?.exists && !addUnmatched) {
80
+ skipped.push(file.name);
81
+ setPercent(Math.round(((idx + 1) / arr.length) * 100));
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ setProgress(`Optimizing ${idx + 1}/${arr.length}: ${file.name}`);
87
+ setPercent(Math.round(((idx + 0.1) / arr.length) * 100));
88
+ const bundle = await optimizeForUpload(file, base);
89
+
90
+ setProgress(`Overwriting ${idx + 1}/${arr.length}: ${file.name}`);
91
+ const all = [bundle.primary, ...bundle.extras];
92
+ const payload = await Promise.all(
93
+ all.map(async (part) => ({
94
+ name: part.name,
95
+ contentType: part.contentType,
96
+ bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
97
+ })),
98
+ );
99
+ await uploadBundle({ data: { files: payload, upsert: true } });
100
+ for (let f = 0; f < all.length; f++) {
101
+ overwritten.push(all[f].name);
102
+ setPercent(Math.round(((idx + 0.1 + (0.9 * (f + 1)) / all.length) / arr.length) * 100));
103
+ }
104
+ } catch (e: any) {
105
+ console.warn("overwrite failed", file.name, e);
106
+ failed.push(`${file.name} — ${e?.message ?? "upload failed"}`);
107
+ }
108
+ }
109
+
110
+ setSummary({ overwritten, skipped, failed });
111
+ try {
112
+ await logRun({ data: { paths: overwritten, skipped, failed } });
113
+ } catch (e) {
114
+ console.warn("audit log failed", e);
115
+ }
116
+
117
+ if (failed.length) {
118
+ toast.error(`${failed.length} file(s) failed — see the summary below.`);
119
+ } else {
120
+ toast.success(
121
+ `Overwrote ${overwritten.length} file(s)${skipped.length ? `, skipped ${skipped.length}` : ""}.`,
122
+ );
123
+ }
124
+ await onDone();
125
+ } catch (e: any) {
126
+ toast.error(e?.message ?? "Re-upload failed");
127
+ } finally {
128
+ setBusy(false);
129
+ setProgress(null);
130
+ setPercent(0);
131
+ if (input.current) input.current.value = "";
132
+ }
133
+ };
134
+
135
+ return (
136
+ <section className="border border-ink/10 bg-ice/40 px-3 py-3 space-y-3">
137
+ <div className="flex flex-wrap items-center gap-3">
138
+ <div className="flex-1 min-w-[16rem] text-[0.72rem] leading-relaxed text-ink/70">
139
+ <strong className="font-medium text-ink">Re-upload &amp; overwrite</strong> — pick the
140
+ original source files again. Matching library files are regenerated at the current quality
141
+ (480/768/1200/1920/2560, q0.90) and written over the same filenames, so nothing needs
142
+ re-linking. Transparent PNGs/WebPs keep alpha and regenerate their PNG fallback.
143
+ Superadmin only.
144
+ </div>
145
+ <label className="inline-flex items-center gap-2 text-[0.7rem] text-ink/70">
146
+ <input
147
+ type="checkbox"
148
+ checked={addUnmatched}
149
+ onChange={(e) => setAddUnmatched(e.target.checked)}
150
+ className="size-3.5 accent-current"
151
+ />
152
+ Also add unmatched files as new
153
+ </label>
154
+ <input
155
+ ref={input}
156
+ type="file"
157
+ multiple
158
+ accept="image/*"
159
+ className="hidden"
160
+ onChange={(e) => run(e.target.files)}
161
+ />
162
+ <button
163
+ type="button"
164
+ onClick={() => input.current?.click()}
165
+ disabled={busy}
166
+ className="inline-flex items-center gap-2 border border-ink px-3 py-1.5 text-[0.7rem] uppercase tracking-widest text-ink hover:bg-ink hover:text-paper disabled:opacity-50"
167
+ >
168
+ {busy ? (
169
+ <Loader2 className="size-3.5 animate-spin" />
170
+ ) : (
171
+ <RefreshCw className="size-3.5" />
172
+ )}
173
+ Re-upload &amp; overwrite
174
+ </button>
175
+ </div>
176
+
177
+ {busy ? (
178
+ <div className="space-y-1.5">
179
+ <div className="flex items-center gap-1.5 text-[0.72rem] text-ink/70">
180
+ <Loader2 className="size-3 animate-spin" />
181
+ <span>{progress ?? "Preparing…"}</span>
182
+ <span className="ml-auto tabular-nums text-ink/50">{percent}%</span>
183
+ </div>
184
+ <div className="h-1.5 w-full overflow-hidden bg-ink/10">
185
+ <div
186
+ className="h-full bg-sea transition-[width] duration-200"
187
+ style={{ width: `${percent}%` }}
188
+ />
189
+ </div>
190
+ </div>
191
+ ) : null}
192
+
193
+ {summary ? (
194
+ <div className="space-y-1 border-t border-ink/10 pt-2 text-[0.7rem] text-ink/70">
195
+ <div>
196
+ Overwritten: <strong className="text-ink">{summary.overwritten.length}</strong> file(s)
197
+ </div>
198
+ {summary.skipped.length ? (
199
+ <details>
200
+ <summary className="cursor-pointer">
201
+ Skipped (no match in library): {summary.skipped.length}
202
+ </summary>
203
+ <ul className="mt-1 list-disc pl-5">
204
+ {summary.skipped.map((s) => (
205
+ <li key={s}>{s}</li>
206
+ ))}
207
+ </ul>
208
+ </details>
209
+ ) : null}
210
+ {summary.failed.length ? (
211
+ <details open>
212
+ <summary className="cursor-pointer text-red-600">
213
+ Failed: {summary.failed.length}
214
+ </summary>
215
+ <ul className="mt-1 list-disc pl-5 text-red-600">
216
+ {summary.failed.map((s) => (
217
+ <li key={s}>{s}</li>
218
+ ))}
219
+ </ul>
220
+ </details>
221
+ ) : null}
222
+ </div>
223
+ ) : null}
224
+ </section>
225
+ );
226
+ }