@bygghammar/cms-platform 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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,
@@ -76,7 +76,10 @@
76
76
  "./backoffice/ProgressBar": "./src/backoffice/ProgressBar.tsx",
77
77
  "./backoffice/PreviewLightbox": "./src/backoffice/PreviewLightbox.tsx",
78
78
  "./backoffice/RichTextEditor": "./src/backoffice/RichTextEditor.tsx",
79
- "./backoffice/media/ConfirmDialog": "./src/backoffice/media/ConfirmDialog.tsx"
79
+ "./backoffice/media/ConfirmDialog": "./src/backoffice/media/ConfirmDialog.tsx",
80
+ "./backoffice/media/MediaCleanup": "./src/backoffice/media/MediaCleanup.tsx",
81
+ "./backoffice/media/MediaReplaceDialog": "./src/backoffice/media/MediaReplaceDialog.tsx",
82
+ "./backoffice/media/ReuploadOverwrite": "./src/backoffice/media/ReuploadOverwrite.tsx"
80
83
  },
81
84
  "dependencies": {
82
85
  "fflate": "^0.8.3"
@@ -0,0 +1,186 @@
1
+ import { useState } from "react";
2
+ import { Loader2, Wand2 } from "lucide-react";
3
+ import { toast } from "sonner";
4
+
5
+ /** Plan returned by the host site's duplicate scan. */
6
+ export type MediaCleanupPlan = {
7
+ scanned: number;
8
+ duplicateBytes: number;
9
+ duplicates: { hash: string; keep: string; remove: string[] }[];
10
+ renames: { from: string; to: string }[];
11
+ };
12
+
13
+ /** Result of applying a cleanup plan. */
14
+ export type MediaCleanupResult = {
15
+ merged: number;
16
+ renamed: number;
17
+ rewritten: number;
18
+ freedBytes: number;
19
+ errors?: string[];
20
+ };
21
+
22
+ function bytes(n: number) {
23
+ if (n < 1024) return `${n} B`;
24
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
25
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
26
+ }
27
+
28
+ /**
29
+ * Superadmin-only library maintenance: merge byte-identical duplicates and
30
+ * rename legacy timestamp-prefixed files. References on the site are repointed
31
+ * first; the extra copies go to the bin, never straight to deletion.
32
+ */
33
+ export function MediaCleanup({
34
+ onDone,
35
+ scanDuplicates,
36
+ runCleanup,
37
+ }: {
38
+ onDone: () => Promise<void> | void;
39
+ /** Host-provided duplicate scan. */
40
+ scanDuplicates: () => Promise<MediaCleanupPlan>;
41
+ /** Host-provided cleanup runner. */
42
+ runCleanup: (input: {
43
+ data: { duplicates: boolean; renames: boolean };
44
+ }) => Promise<MediaCleanupResult>;
45
+ }) {
46
+ const scan = scanDuplicates;
47
+ const apply = runCleanup;
48
+ const [open, setOpen] = useState(false);
49
+ const [busy, setBusy] = useState(false);
50
+ const [plan, setPlan] = useState<MediaCleanupPlan | null>(null);
51
+
52
+ const doScan = async () => {
53
+ setBusy(true);
54
+ try {
55
+ const result = (await scan()) as MediaCleanupPlan;
56
+ setPlan(result);
57
+ setOpen(true);
58
+ } catch (error) {
59
+ toast.error(error instanceof Error ? error.message : "Scan failed");
60
+ } finally {
61
+ setBusy(false);
62
+ }
63
+ };
64
+
65
+ const doApply = async (opts: { duplicates: boolean; renames: boolean }) => {
66
+ setBusy(true);
67
+ try {
68
+ const res = await apply({ data: opts });
69
+ toast.success(
70
+ `Merged ${res.merged} duplicate${res.merged === 1 ? "" : "s"}, renamed ${res.renamed}, updated ${res.rewritten} reference${res.rewritten === 1 ? "" : "s"} (${bytes(res.freedBytes)} freed).`,
71
+ );
72
+ if (res.errors?.length) toast.error(res.errors.slice(0, 3).join(" · "));
73
+ setPlan(null);
74
+ setOpen(false);
75
+ await onDone();
76
+ } catch (error) {
77
+ toast.error(error instanceof Error ? error.message : "Cleanup failed");
78
+ } finally {
79
+ setBusy(false);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <>
85
+ <button
86
+ type="button"
87
+ onClick={doScan}
88
+ disabled={busy}
89
+ title="Find duplicate files and legacy timestamp filenames"
90
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-sm hover:border-sea disabled:opacity-50"
91
+ >
92
+ {busy && !open ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
93
+ Clean up
94
+ </button>
95
+
96
+ {open && plan ? (
97
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 p-4">
98
+ <div className="w-full max-w-2xl max-h-[85vh] overflow-auto bg-white border border-ink/10">
99
+ <header className="px-5 py-4 border-b border-ink/10">
100
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
101
+ Library cleanup
102
+ </div>
103
+ <div className="text-sm text-ink/70 mt-1">
104
+ {plan.scanned} files scanned ·{" "}
105
+ {plan.duplicates.reduce((n, g) => n + g.remove.length, 0)} duplicate cop
106
+ {plan.duplicates.reduce((n, g) => n + g.remove.length, 0) === 1 ? "y" : "ies"} (
107
+ {bytes(plan.duplicateBytes)}) · {plan.renames.length} timestamp name
108
+ {plan.renames.length === 1 ? "" : "s"}
109
+ </div>
110
+ </header>
111
+
112
+ <div className="p-5 space-y-5 text-sm">
113
+ {plan.duplicates.length > 0 ? (
114
+ <section className="space-y-2">
115
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
116
+ Duplicates to merge
117
+ </div>
118
+ {plan.duplicates.map((g) => (
119
+ <div key={g.hash} className="border border-ink/10 p-3">
120
+ <div className="text-ink">Keep: {g.keep}</div>
121
+ <ul className="mt-1 text-ink/60 text-xs list-disc pl-5">
122
+ {g.remove.map((p) => (
123
+ <li key={p}>{p}</li>
124
+ ))}
125
+ </ul>
126
+ </div>
127
+ ))}
128
+ </section>
129
+ ) : null}
130
+
131
+ {plan.renames.length > 0 ? (
132
+ <section className="space-y-2">
133
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
134
+ Renames
135
+ </div>
136
+ <ul className="text-xs text-ink/70 space-y-1">
137
+ {plan.renames.map((r) => (
138
+ <li key={r.from}>
139
+ {r.from} <span className="text-ink/40">→</span> {r.to}
140
+ </li>
141
+ ))}
142
+ </ul>
143
+ </section>
144
+ ) : null}
145
+
146
+ {plan.duplicates.length === 0 && plan.renames.length === 0 ? (
147
+ <div className="text-ink/60">Nothing to clean up — the library is tidy.</div>
148
+ ) : null}
149
+ </div>
150
+
151
+ <footer className="px-5 py-4 border-t border-ink/10 flex flex-wrap items-center justify-end gap-2">
152
+ <button
153
+ type="button"
154
+ onClick={() => setOpen(false)}
155
+ className="border border-ink/15 px-4 py-2 text-sm hover:border-sea"
156
+ >
157
+ Close
158
+ </button>
159
+ {plan.renames.length > 0 ? (
160
+ <button
161
+ type="button"
162
+ disabled={busy}
163
+ onClick={() => doApply({ duplicates: false, renames: true })}
164
+ className="border border-ink/15 px-4 py-2 text-sm hover:border-sea disabled:opacity-50"
165
+ >
166
+ Rename only
167
+ </button>
168
+ ) : null}
169
+ {plan.duplicates.length > 0 || plan.renames.length > 0 ? (
170
+ <button
171
+ type="button"
172
+ disabled={busy}
173
+ onClick={() => doApply({ duplicates: true, renames: true })}
174
+ className="inline-flex items-center gap-2 bg-ink text-paper px-4 py-2 text-sm hover:bg-sea disabled:opacity-50"
175
+ >
176
+ {busy ? <Loader2 className="size-4 animate-spin" /> : null}
177
+ Merge duplicates & rename
178
+ </button>
179
+ ) : null}
180
+ </footer>
181
+ </div>
182
+ </div>
183
+ ) : null}
184
+ </>
185
+ );
186
+ }
@@ -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
+ }