@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.
- package/package.json +10 -2
- package/src/backoffice/SaveGlobalDialog.tsx +221 -0
- package/src/backoffice/forms/FieldEditor.tsx +611 -0
- package/src/backoffice/forms/FormPreview.tsx +108 -0
- package/src/backoffice/forms/TestDeliveryPanel.tsx +201 -0
- package/src/backoffice/forms/VersionHistory.tsx +142 -0
- package/src/backoffice/media/MediaCleanup.tsx +186 -0
- package/src/backoffice/media/MediaReplaceDialog.tsx +158 -0
- package/src/backoffice/media/ReuploadOverwrite.tsx +226 -0
|
@@ -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 & 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 & 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
|
+
}
|