@bygghammar/cms-platform 0.8.1 → 0.10.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.8.1",
3
+ "version": "0.10.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,
@@ -84,7 +84,12 @@
84
84
  "./backoffice/forms/FormPreview": "./src/backoffice/forms/FormPreview.tsx",
85
85
  "./backoffice/forms/VersionHistory": "./src/backoffice/forms/VersionHistory.tsx",
86
86
  "./backoffice/forms/TestDeliveryPanel": "./src/backoffice/forms/TestDeliveryPanel.tsx",
87
- "./backoffice/SaveGlobalDialog": "./src/backoffice/SaveGlobalDialog.tsx"
87
+ "./backoffice/SaveGlobalDialog": "./src/backoffice/SaveGlobalDialog.tsx",
88
+ "./backoffice/BlockPicker": "./src/backoffice/BlockPicker.tsx",
89
+ "./backoffice/media/FocusPicker": "./src/backoffice/media/FocusPicker.tsx",
90
+ "./backoffice/media/MediaMetaDialog": "./src/backoffice/media/MediaMetaDialog.tsx",
91
+ "./backoffice/SectionThumb": "./src/backoffice/SectionThumb.tsx",
92
+ "./backoffice/PageImagePicker": "./src/backoffice/PageImagePicker.tsx"
88
93
  },
89
94
  "dependencies": {
90
95
  "fflate": "^0.8.3"
@@ -0,0 +1,267 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Plus, Search, X, Layers, BookmarkPlus, Trash, LayoutTemplate, Globe } from "lucide-react";
4
+ import type { BlockInstance, BlockProps } from "../blocks/types";
5
+ import type { BlockDef } from "../blocks/registry";
6
+
7
+ function uid(prefix = "b") {
8
+ return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
9
+ }
10
+
11
+ /** Saved one-off block copy ("template") shape the picker needs. */
12
+ export type BlockPickerTemplate = {
13
+ id: string;
14
+ name: string;
15
+ description?: string | null;
16
+ blockType: string;
17
+ block: BlockInstance;
18
+ };
19
+
20
+ export type BlockPickerGlobal = {
21
+ key: string;
22
+ name: string;
23
+ kind?: "block" | "section";
24
+ blocks?: BlockInstance[];
25
+ };
26
+
27
+ type Entry = {
28
+ id: string;
29
+ group: "blocks" | "globalBlocks" | "globalSections" | "templates";
30
+ title: string;
31
+ subtitle: string;
32
+ badge: string;
33
+ make: () => BlockInstance;
34
+ onDelete?: () => void;
35
+ };
36
+
37
+ /**
38
+ * Site-agnostic "Add block" dialog. The host injects its own block definitions
39
+ * so each site keeps its own block registry.
40
+ * Backoffice copy is English by project rule.
41
+ */
42
+ export function BlockPicker({
43
+ onAdd,
44
+ globalSections,
45
+ templates,
46
+ onDeleteTemplate,
47
+ blockDefs,
48
+ labelForType,
49
+ label = "Add block",
50
+ hideGlobals = false,
51
+ }: {
52
+ onAdd: (b: BlockInstance) => void;
53
+ globalSections: BlockPickerGlobal[];
54
+ templates: BlockPickerTemplate[];
55
+ onDeleteTemplate: (id: string) => void;
56
+ /** All block types this site offers. */
57
+ blockDefs: BlockDef[];
58
+ /** Human label for a block type (falls back to the raw type). */
59
+ labelForType?: (type: string) => string;
60
+ label?: string;
61
+ /** Global blocks cannot nest other globals. */
62
+ hideGlobals?: boolean;
63
+ }) {
64
+ const [open, setOpen] = useState(false);
65
+ const [q, setQ] = useState("");
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+
68
+ useEffect(() => {
69
+ if (!open) return;
70
+ setQ("");
71
+ const prev = document.body.style.overflow;
72
+ document.body.style.overflow = "hidden";
73
+ const onKey = (e: KeyboardEvent) => {
74
+ if (e.key === "Escape") setOpen(false);
75
+ };
76
+ document.addEventListener("keydown", onKey);
77
+ const t = window.setTimeout(() => inputRef.current?.focus(), 30);
78
+ return () => {
79
+ document.body.style.overflow = prev;
80
+ document.removeEventListener("keydown", onKey);
81
+ window.clearTimeout(t);
82
+ };
83
+ }, [open]);
84
+
85
+ const typeLabel = (type: string) => labelForType?.(type) || type;
86
+
87
+ const entries = useMemo<Entry[]>(() => {
88
+ const blocks: Entry[] = blockDefs.map((def) => ({
89
+ id: `b:${def.type}`,
90
+ group: "blocks",
91
+ title: def.label,
92
+ subtitle: def.description ?? "",
93
+ badge: def.type,
94
+ make: () => ({
95
+ id: uid(def.type),
96
+ type: def.type,
97
+ props: structuredClone(def.defaultProps) as BlockProps,
98
+ }),
99
+ }));
100
+ const globals: Entry[] = hideGlobals
101
+ ? []
102
+ : globalSections.map((g) => ({
103
+ id: `g:${g.key}`,
104
+ group: (g.kind ?? "section") === "block" ? "globalBlocks" : "globalSections",
105
+ title: g.name,
106
+ subtitle:
107
+ (g.blocks ?? []).map((b) => b.name?.trim() || typeLabel(b.type)).join(" · ") || g.key,
108
+ badge: (g.kind ?? "section") === "block" ? "global block" : "global section",
109
+ make: () => ({
110
+ id: uid("gref"),
111
+ type: "globalRef",
112
+ name: g.name,
113
+ props: { sectionKey: g.key },
114
+ }),
115
+ }));
116
+ const tpls: Entry[] = templates.map((tpl) => ({
117
+ id: `t:${tpl.id}`,
118
+ group: "templates",
119
+ title: tpl.name,
120
+ subtitle:
121
+ (tpl.block?.name?.trim() || typeLabel(tpl.blockType)) +
122
+ (tpl.description ? ` · ${tpl.description}` : ""),
123
+ badge: "copy",
124
+ make: () => {
125
+ const clone = structuredClone(tpl.block);
126
+ clone.id = uid(clone.type);
127
+ return clone;
128
+ },
129
+ onDelete: () => {
130
+ if (confirm(`Delete copy "${tpl.name}"?`)) onDeleteTemplate(tpl.id);
131
+ },
132
+ }));
133
+ return [...blocks, ...globals, ...tpls];
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [blockDefs, globalSections, templates, onDeleteTemplate, hideGlobals, labelForType]);
136
+
137
+ const filtered = useMemo(() => {
138
+ const term = q.trim().toLowerCase();
139
+ if (!term) return entries;
140
+ return entries.filter(
141
+ (e) =>
142
+ e.title.toLowerCase().includes(term) ||
143
+ e.subtitle.toLowerCase().includes(term) ||
144
+ e.badge.toLowerCase().includes(term),
145
+ );
146
+ }, [entries, q]);
147
+
148
+ const pick = (e: Entry) => {
149
+ onAdd(e.make());
150
+ setOpen(false);
151
+ };
152
+
153
+ const groups: { key: Entry["group"]; label: string; icon: ReactNode }[] = [
154
+ { key: "blocks", label: "Block types", icon: <LayoutTemplate className="size-3" /> },
155
+ { key: "globalBlocks", label: "Global blocks", icon: <Globe className="size-3" /> },
156
+ { key: "globalSections", label: "Global sections", icon: <Layers className="size-3" /> },
157
+ { key: "templates", label: "One-off copies", icon: <BookmarkPlus className="size-3" /> },
158
+ ];
159
+
160
+ return (
161
+ <>
162
+ <button
163
+ onClick={() => setOpen(true)}
164
+ className="w-full inline-flex items-center justify-center gap-2 border border-dashed border-ink/25 px-3 py-2 text-xs uppercase tracking-widest text-ink/70 hover:border-sea hover:text-ink"
165
+ >
166
+ <Plus className="size-4" /> {label}
167
+ </button>
168
+
169
+ {open &&
170
+ typeof document !== "undefined" &&
171
+ createPortal(
172
+ <div
173
+ className="fixed inset-0 z-[100] flex items-start justify-center bg-ink/60 p-4 sm:p-8"
174
+ onClick={() => setOpen(false)}
175
+ >
176
+ <div
177
+ className="mt-4 flex max-h-[88vh] w-full max-w-5xl flex-col border border-ink/15 bg-white shadow-2xl"
178
+ onClick={(e) => e.stopPropagation()}
179
+ >
180
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-ink/10 px-4 py-3">
181
+ <div className="min-w-0">
182
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
183
+ Add block
184
+ </div>
185
+ <div className="mt-2 flex items-center gap-2 border border-ink/15 px-2 py-1.5">
186
+ <Search className="size-4 shrink-0 text-ink/40" />
187
+ <input
188
+ ref={inputRef}
189
+ value={q}
190
+ onChange={(e) => setQ(e.target.value)}
191
+ onKeyDown={(e) => {
192
+ if (e.key === "Enter" && filtered[0]) pick(filtered[0]);
193
+ }}
194
+ placeholder="Search blocks, global blocks, templates…"
195
+ className="min-w-0 flex-1 bg-transparent text-sm outline-none"
196
+ />
197
+ </div>
198
+ </div>
199
+ <button
200
+ onClick={() => setOpen(false)}
201
+ className="shrink-0 self-start p-2 text-ink/50 hover:text-ink"
202
+ title="Close"
203
+ >
204
+ <X className="size-4" />
205
+ </button>
206
+ </div>
207
+
208
+ <div className="flex-1 overflow-y-auto p-4">
209
+ {filtered.length === 0 && (
210
+ <div className="py-12 text-center text-sm text-ink/50">No matches for “{q}”.</div>
211
+ )}
212
+ {groups.map((g) => {
213
+ const items = filtered.filter((e) => e.group === g.key);
214
+ if (items.length === 0) return null;
215
+ return (
216
+ <div key={g.key} className="mb-6 last:mb-0">
217
+ <div className="mb-2 flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.22em] text-ink/45">
218
+ {g.icon} {g.label}
219
+ <span className="text-ink/30">({items.length})</span>
220
+ </div>
221
+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
222
+ {items.map((e) => (
223
+ <div key={e.id} className="group relative">
224
+ <button
225
+ onClick={() => pick(e)}
226
+ className="h-full w-full border border-ink/12 bg-white p-3 text-left transition-colors hover:border-sea hover:bg-ice/40"
227
+ >
228
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2">
229
+ <div className="min-w-0 text-sm font-medium text-ink">
230
+ {e.title}
231
+ </div>
232
+ <span className="shrink-0 border border-ink/10 bg-ink/[0.04] px-1.5 py-0.5 font-mono text-[0.6rem] text-ink/50">
233
+ {e.badge}
234
+ </span>
235
+ </div>
236
+ {e.subtitle && (
237
+ <div className="mt-1 line-clamp-2 text-[0.72rem] text-ink/55">
238
+ {e.subtitle}
239
+ </div>
240
+ )}
241
+ </button>
242
+ {e.onDelete && (
243
+ <button
244
+ onClick={(ev) => {
245
+ ev.stopPropagation();
246
+ e.onDelete?.();
247
+ }}
248
+ className="absolute right-1.5 bottom-1.5 p-1 text-red-500 opacity-0 hover:text-red-700 group-hover:opacity-100"
249
+ title="Delete template"
250
+ >
251
+ <Trash className="size-3" />
252
+ </button>
253
+ )}
254
+ </div>
255
+ ))}
256
+ </div>
257
+ </div>
258
+ );
259
+ })}
260
+ </div>
261
+ </div>
262
+ </div>,
263
+ document.body,
264
+ )}
265
+ </>
266
+ );
267
+ }
@@ -0,0 +1,157 @@
1
+ import { useState } from "react";
2
+ import { Images, X } from "lucide-react";
3
+
4
+ export type PageImageCandidate = {
5
+ url: string;
6
+ /** Where the image was found — e.g. "Hero" or "Site · gallery". */
7
+ source: string;
8
+ /** Hero-ish images float to the top of the list. */
9
+ hero: boolean;
10
+ };
11
+
12
+ const VIDEO_RE = /\.(mp4|webm|mov|m3u8)(\?|$)/i;
13
+
14
+ export function isPickableImageUrl(value: unknown): value is string {
15
+ return (
16
+ typeof value === "string" &&
17
+ value.length > 4 &&
18
+ /^(https?:\/\/|\/)/.test(value) &&
19
+ !VIDEO_RE.test(value) &&
20
+ (/\.(png|jpe?g|webp|avif|gif|svg)(\?|$)/i.test(value) ||
21
+ value.includes("/storage/v1/object/public/"))
22
+ );
23
+ }
24
+
25
+ /** Recursively collect every image URL inside an arbitrary props/content tree. */
26
+ export function collectImages(node: unknown, out: string[], depth = 0) {
27
+ if (node == null || depth > 8) return;
28
+ if (typeof node === "string") {
29
+ if (isPickableImageUrl(node)) out.push(node);
30
+ return;
31
+ }
32
+ if (Array.isArray(node)) {
33
+ for (const item of node) collectImages(item, out, depth + 1);
34
+ return;
35
+ }
36
+ if (typeof node === "object") {
37
+ for (const value of Object.values(node as Record<string, unknown>)) {
38
+ collectImages(value, out, depth + 1);
39
+ }
40
+ }
41
+ }
42
+
43
+ export function isHeroishLabel(label: string) {
44
+ return /hero|header|cover|banner/i.test(label);
45
+ }
46
+
47
+ export type PageImageSource = {
48
+ /** Page-builder blocks (already resolved, including referenced globals). */
49
+ blocks?: ReadonlyArray<{ type: string; name?: string | null; props?: unknown }>;
50
+ /** Shared site-content sections keyed by section name. */
51
+ siteSections?: Record<string, unknown>;
52
+ };
53
+
54
+ /**
55
+ * Gathers every image rendered on a page: first from the page-builder blocks,
56
+ * then from the shared site content sections used by coded pages.
57
+ *
58
+ * Host-agnostic: the host supplies already-fetched blocks and site sections.
59
+ */
60
+ export function buildPageImageCandidates({
61
+ blocks,
62
+ siteSections,
63
+ }: PageImageSource): PageImageCandidate[] {
64
+ const seen = new Set<string>();
65
+ const list: PageImageCandidate[] = [];
66
+ const push = (url: string, source: string, hero: boolean) => {
67
+ if (seen.has(url)) return;
68
+ seen.add(url);
69
+ list.push({ url, source, hero });
70
+ };
71
+
72
+ for (const block of blocks ?? []) {
73
+ const label = block.name || block.type;
74
+ const urls: string[] = [];
75
+ collectImages(block.props, urls);
76
+ for (const url of urls) push(url, label, isHeroishLabel(label));
77
+ }
78
+
79
+ for (const [key, value] of Object.entries(siteSections ?? {})) {
80
+ const urls: string[] = [];
81
+ collectImages(value, urls);
82
+ for (const url of urls) push(url, `Site · ${key}`, isHeroishLabel(key));
83
+ }
84
+
85
+ return list.sort((a, b) => Number(b.hero) - Number(a.hero));
86
+ }
87
+
88
+ export function PageImagePickerDialog({
89
+ candidates,
90
+ onPick,
91
+ onClose,
92
+ }: {
93
+ candidates: PageImageCandidate[];
94
+ onPick: (url: string) => void;
95
+ onClose: () => void;
96
+ }) {
97
+ const [q, setQ] = useState("");
98
+ const filtered = candidates.filter(
99
+ (c) =>
100
+ !q ||
101
+ c.url.toLowerCase().includes(q.toLowerCase()) ||
102
+ c.source.toLowerCase().includes(q.toLowerCase()),
103
+ );
104
+
105
+ return (
106
+ <div
107
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
108
+ onClick={onClose}
109
+ >
110
+ <div
111
+ className="w-full max-w-4xl max-h-[85vh] bg-white border border-ink/15 flex flex-col"
112
+ onClick={(e) => e.stopPropagation()}
113
+ >
114
+ <div className="flex items-center gap-2 border-b border-ink/10 p-3">
115
+ <Images className="size-4 text-ink/60" />
116
+ <span className="text-sm font-medium text-ink">Images on this page</span>
117
+ <input
118
+ value={q}
119
+ onChange={(e) => setQ(e.target.value)}
120
+ placeholder="Search…"
121
+ className="ml-3 flex-1 border border-ink/15 px-2 py-1 text-sm focus:outline-none focus:border-sea"
122
+ />
123
+ <button onClick={onClose} className="text-ink/50 hover:text-ink p-1">
124
+ <X className="size-4" />
125
+ </button>
126
+ </div>
127
+ <div className="flex-1 overflow-auto p-3">
128
+ {filtered.length === 0 ? (
129
+ <div className="text-sm text-ink/60">No images found on this page yet.</div>
130
+ ) : (
131
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
132
+ {filtered.map((c) => (
133
+ <button
134
+ key={c.url}
135
+ type="button"
136
+ onClick={() => {
137
+ onPick(c.url);
138
+ onClose();
139
+ }}
140
+ className="group border border-ink/10 hover:border-sea text-left"
141
+ >
142
+ <img src={c.url} alt="" loading="lazy" className="h-28 w-full object-cover" />
143
+ <div className="p-2">
144
+ <div className="truncate text-xs text-ink">{c.source}</div>
145
+ <div className="truncate text-[0.65rem] text-ink/45">
146
+ {decodeURIComponent(c.url.split("/").pop() ?? "")}
147
+ </div>
148
+ </div>
149
+ </button>
150
+ ))}
151
+ </div>
152
+ )}
153
+ </div>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,389 @@
1
+ import { useMemo, type ComponentType } from "react";
2
+ import { Layout } from "lucide-react";
3
+
4
+ /**
5
+ * Static, cheap section/block thumbnail.
6
+ *
7
+ * Renders directly from the *saved* block data, so it only changes when an
8
+ * editor saves — no live iframe, no per-render reload. Images use the smallest
9
+ * responsive variant available.
10
+ */
11
+
12
+ const THUMB_W = 128;
13
+ const THUMB_H = 80;
14
+
15
+ const VARIANT_RE = /-(480|768|1200|1920)\.webp(\?.*)?$/i;
16
+
17
+ /** Prefer the tiny 480px WebP variant when the asset has one. */
18
+ function smallestVariant(url: string): string {
19
+ const m = url.match(VARIANT_RE);
20
+ if (!m) return url;
21
+ return `${url.slice(0, url.length - m[0].length)}-480.webp${m[2] ?? ""}`;
22
+ }
23
+
24
+ function isImageUrl(value: unknown): value is string {
25
+ if (typeof value !== "string" || value.length === 0) return false;
26
+ if (!/^(https?:\/\/|\/)/.test(value)) return false;
27
+ return !/\.(mp4|webm|mov)(\?|$)/i.test(value);
28
+ }
29
+
30
+ /** Image keys, in the order a block would actually render them. */
31
+ const IMAGE_KEYS = [
32
+ "src",
33
+ "poster",
34
+ "posterSrc",
35
+ "url",
36
+ "image",
37
+ "imageSrc",
38
+ "thumbnail",
39
+ "backgroundImage",
40
+ ];
41
+
42
+ /** Depth-first search for the first usable image URL inside a section blob. */
43
+ function findImage(node: unknown, depth = 0): string | null {
44
+ if (node == null || depth > 6) return null;
45
+ if (Array.isArray(node)) {
46
+ for (const item of node) {
47
+ const found = findImage(item, depth + 1);
48
+ if (found) return found;
49
+ }
50
+ return null;
51
+ }
52
+ if (typeof node !== "object") return null;
53
+ const obj = node as Record<string, unknown>;
54
+ // A block that renders video shows its poster — not an unused image prop.
55
+ const keys = obj["mediaType"] === "video" ? ["posterSrc", "poster", ...IMAGE_KEYS] : IMAGE_KEYS;
56
+ for (const key of keys) {
57
+ const value = obj[key];
58
+ if (isImageUrl(value)) return value;
59
+ }
60
+ for (const value of Object.values(obj)) {
61
+ const found = findImage(value, depth + 1);
62
+ if (found) return found;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function findText(node: unknown, depth = 0): string | null {
68
+ if (node == null || depth > 4) return null;
69
+ if (Array.isArray(node)) {
70
+ for (const item of node) {
71
+ const found = findText(item, depth + 1);
72
+ if (found) return found;
73
+ }
74
+ return null;
75
+ }
76
+ if (typeof node !== "object") return null;
77
+ const obj = node as Record<string, unknown>;
78
+ for (const key of ["title", "name", "label", "eyebrow"]) {
79
+ const value = obj[key];
80
+ if (typeof value === "string" && value.trim()) return value;
81
+ if (value && typeof value === "object") {
82
+ const sv = (value as Record<string, unknown>).sv;
83
+ if (typeof sv === "string" && sv.trim()) return sv;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** Tiny schematic of a block's layout, so text-only blocks stay distinct. */
90
+ function Wireframe({ blockType }: { blockType?: string }) {
91
+ const bar = (w: string, tone = "bg-ink/15") => <div className={`h-1 ${w} ${tone}`} />;
92
+ const box = (extra = "") => <div className={`bg-ink/10 ${extra}`} />;
93
+
94
+ switch (blockType) {
95
+ case "hero":
96
+ return (
97
+ <div className="flex size-full flex-col justify-end gap-1 bg-ink/70 p-2">
98
+ <div className="h-1 w-6 bg-sea" />
99
+ <div className="h-1.5 w-4/5 bg-white/80" />
100
+ <div className="h-1 w-3/5 bg-white/40" />
101
+ </div>
102
+ );
103
+ case "heroFull":
104
+ return (
105
+ <div className="flex size-full flex-col justify-center gap-1 bg-ink/75 p-2">
106
+ <div className="h-1 w-8 bg-sea" />
107
+ <div className="h-2 w-4/5 bg-white/85" />
108
+ <div className="h-1 w-2/3 bg-white/45" />
109
+ <div className="mt-1 flex gap-1">
110
+ <div className="h-1.5 w-6 bg-white/80" />
111
+ <div className="h-1.5 w-6 border border-white/60" />
112
+ </div>
113
+ </div>
114
+ );
115
+ case "stats":
116
+ return (
117
+ <div className="grid size-full grid-cols-3 items-center gap-1 p-2">
118
+ {[0, 1, 2].map((i) => (
119
+ <div key={i} className="flex flex-col items-center gap-1">
120
+ <div className="h-2 w-5 bg-sea/60" />
121
+ <div className="h-1 w-6 bg-ink/15" />
122
+ </div>
123
+ ))}
124
+ </div>
125
+ );
126
+ case "features":
127
+ return (
128
+ <div className="grid size-full grid-cols-3 grid-rows-2 gap-1 p-2">
129
+ {Array.from({ length: 6 }).map((_, i) => (
130
+ <div key={i} className="bg-ink/10" />
131
+ ))}
132
+ </div>
133
+ );
134
+ case "faq":
135
+ return (
136
+ <div className="flex size-full flex-col justify-center gap-1.5 p-2">
137
+ {[0, 1, 2].map((i) => (
138
+ <div key={i} className="flex items-center gap-1">
139
+ <div className="size-1.5 bg-sea/60" />
140
+ {bar("flex-1")}
141
+ </div>
142
+ ))}
143
+ </div>
144
+ );
145
+ case "checklist":
146
+ return (
147
+ <div className="flex size-full flex-col justify-center gap-1.5 p-2">
148
+ {[0, 1, 2, 3].map((i) => (
149
+ <div key={i} className="flex items-center gap-1">
150
+ <div className="size-1.5 rounded-full bg-sea/60" />
151
+ {bar(i % 2 ? "w-2/3" : "w-full")}
152
+ </div>
153
+ ))}
154
+ </div>
155
+ );
156
+ case "imageMarquee":
157
+ return (
158
+ <div className="grid size-full grid-cols-3 gap-1 p-2">
159
+ <div className="flex flex-col justify-center gap-1">
160
+ <div className="h-1 w-4/5 bg-ink/40" />
161
+ <div className="h-1 w-full bg-ink/15" />
162
+ <div className="h-1 w-3/4 bg-ink/15" />
163
+ </div>
164
+ <div className="flex flex-col gap-1">
165
+ <div className="h-4 bg-sea/25" />
166
+ <div className="h-5 bg-ink/15" />
167
+ </div>
168
+ <div className="flex flex-col justify-end gap-1">
169
+ <div className="h-5 bg-ink/15" />
170
+ <div className="h-4 bg-sea/25" />
171
+ </div>
172
+ </div>
173
+ );
174
+ case "iconGrid":
175
+ return (
176
+ <div className="grid size-full grid-cols-3 items-center gap-1 p-2">
177
+ {[0, 1, 2, 3, 4, 5].map((i) => (
178
+ <div key={i} className="flex flex-col items-center gap-1">
179
+ <div className="size-2 rounded-full border border-sea/70" />
180
+ <div className="h-1 w-5 bg-ink/15" />
181
+ </div>
182
+ ))}
183
+ </div>
184
+ );
185
+ case "richText":
186
+ return (
187
+ <div className="flex size-full flex-col justify-center gap-1 p-2">
188
+ <div className="h-2 w-1/2 bg-ink/45" />
189
+ {bar("w-full")}
190
+ {bar("w-11/12")}
191
+ <div className="flex gap-1">
192
+ <div className="h-1 w-6 bg-sea/70" />
193
+ {bar("w-1/3")}
194
+ </div>
195
+ {bar("w-3/4")}
196
+ </div>
197
+ );
198
+ case "prose":
199
+ return (
200
+ <div className="flex size-full flex-col justify-center gap-1 p-2">
201
+ <div className="h-1.5 w-2/3 bg-ink/40" />
202
+ {bar("w-full")}
203
+ {bar("w-full")}
204
+ {bar("w-4/5")}
205
+ </div>
206
+ );
207
+ case "tripList":
208
+ case "courseList":
209
+ return (
210
+ <div className="flex size-full flex-col justify-center gap-1 p-2">
211
+ {[0, 1, 2].map((i) => (
212
+ <div
213
+ key={i}
214
+ className="flex items-center justify-between gap-2 border border-ink/10 px-1 py-0.5"
215
+ >
216
+ {bar("w-8")}
217
+ <div className="h-1 w-3 bg-sea/60" />
218
+ </div>
219
+ ))}
220
+ </div>
221
+ );
222
+ case "ctaBanner":
223
+ case "contactCta":
224
+ return (
225
+ <div className="flex size-full flex-col items-center justify-center gap-1 bg-sea/10 p-2">
226
+ <div className="h-1.5 w-3/5 bg-ink/35" />
227
+ {bar("w-2/5")}
228
+ <div className="mt-1 h-2 w-10 bg-sea/70" />
229
+ </div>
230
+ );
231
+ case "mediaSplit":
232
+ return (
233
+ <div className="grid size-full grid-cols-2 gap-1 p-2">
234
+ {box()}
235
+ <div className="flex flex-col justify-center gap-1">
236
+ <div className="h-1.5 w-4/5 bg-ink/30" />
237
+ {bar("w-full")}
238
+ {bar("w-3/5")}
239
+ </div>
240
+ </div>
241
+ );
242
+ case "gallery":
243
+ return (
244
+ <div className="grid size-full grid-cols-2 grid-rows-2 gap-1 p-2">
245
+ {box("row-span-2")}
246
+ {box()}
247
+ {box()}
248
+ </div>
249
+ );
250
+ case "testimonials":
251
+ return (
252
+ <div className="flex size-full items-center gap-1 p-2">
253
+ {[0, 1].map((i) => (
254
+ <div key={i} className="flex flex-1 flex-col gap-1 border border-ink/10 p-1">
255
+ {bar("w-full")}
256
+ {bar("w-3/4")}
257
+ <div className="mt-1 flex items-center gap-1">
258
+ <div className="size-2 rounded-full bg-ink/15" />
259
+ {bar("w-4")}
260
+ </div>
261
+ </div>
262
+ ))}
263
+ </div>
264
+ );
265
+ case "instagram":
266
+ return (
267
+ <div className="grid size-full grid-cols-4 grid-rows-2 gap-0.5 p-1.5">
268
+ {Array.from({ length: 8 }).map((_, i) => (
269
+ <div key={i} className="bg-ink/10" />
270
+ ))}
271
+ </div>
272
+ );
273
+ case "trips":
274
+ case "packages":
275
+ case "destinations":
276
+ return (
277
+ <div className="flex size-full items-stretch gap-1 p-2">
278
+ {[0, 1, 2].map((i) => (
279
+ <div key={i} className="flex flex-1 flex-col gap-1 border border-ink/10 p-1">
280
+ <div className="h-4 bg-ink/10" />
281
+ {bar("w-full")}
282
+ </div>
283
+ ))}
284
+ </div>
285
+ );
286
+ case "intro":
287
+ return (
288
+ <div className="flex size-full flex-col justify-center gap-1 p-2 text-center">
289
+ <div className="mx-auto h-1 w-4 bg-sea/60" />
290
+ <div className="mx-auto h-1.5 w-3/5 bg-ink/35" />
291
+ <div className="mx-auto h-1 w-4/5 bg-ink/15" />
292
+ </div>
293
+ );
294
+ case "globalRef":
295
+ return (
296
+ <div className="flex size-full flex-col items-center justify-center gap-1 border-2 border-dashed border-sea/40 p-2">
297
+ <Layout className="size-4 text-sea/60" />
298
+ <div className="h-1 w-8 bg-sea/30" />
299
+ </div>
300
+ );
301
+ default:
302
+ return (
303
+ <div className="flex size-full items-center justify-center">
304
+ <Layout className="size-5 text-ink/30" />
305
+ </div>
306
+ );
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Same static thumbnail, driven by any saved data blob (e.g. the current
312
+ * global block list of a global block). Never renders video — only the first
313
+ * image / poster found in the data.
314
+ *
315
+ * Fallback order: image inside the data → `fallbackImage` (a reference image
316
+ * taken from a real usage of this block type) → a per-type layout wireframe.
317
+ */
318
+ export type ThumbImageProps = {
319
+ src: string;
320
+ alt: string;
321
+ className?: string;
322
+ width?: number;
323
+ height?: number;
324
+ loading?: "lazy" | "eager";
325
+ decoding?: "async" | "sync" | "auto";
326
+ responsive?: boolean;
327
+ "aria-hidden"?: "true";
328
+ };
329
+
330
+ export function DataThumb({
331
+ data,
332
+ fallbackImage,
333
+ blockType,
334
+ Image = "img" as unknown as ComponentType<ThumbImageProps>,
335
+ }: {
336
+ data: unknown;
337
+ fallbackImage?: string | null;
338
+ blockType?: string;
339
+ /** Host image component (e.g. a focal-point aware <img> wrapper). */
340
+ Image?: ComponentType<ThumbImageProps>;
341
+ }) {
342
+ const { image, text } = useMemo(() => {
343
+ const found = findImage(data) ?? fallbackImage ?? null;
344
+ return {
345
+ image: found ? smallestVariant(found) : null,
346
+ text: findText(data),
347
+ };
348
+ }, [data, fallbackImage]);
349
+
350
+ const style = { width: THUMB_W, height: THUMB_H };
351
+
352
+ if (image) {
353
+ return (
354
+ <div
355
+ className="relative shrink-0 border border-ink/10 bg-ink/5 overflow-hidden"
356
+ style={style}
357
+ >
358
+ <Image
359
+ src={image}
360
+ responsive={false}
361
+ alt=""
362
+ aria-hidden="true"
363
+ loading="lazy"
364
+ decoding="async"
365
+ width={THUMB_W}
366
+ height={THUMB_H}
367
+ className="size-full object-cover"
368
+ />
369
+ </div>
370
+ );
371
+ }
372
+
373
+ return (
374
+ <div className="relative shrink-0 border border-ink/10 bg-white overflow-hidden" style={style}>
375
+ {text && !blockType ? (
376
+ <div className="flex size-full flex-col justify-center gap-1.5 p-2">
377
+ <div className="h-1 w-6 bg-sea/60" />
378
+ <div className="text-[0.5rem] leading-tight text-ink/70 line-clamp-2 font-serif">
379
+ {text}
380
+ </div>
381
+ <div className="h-1 w-full bg-ink/10" />
382
+ <div className="h-1 w-3/4 bg-ink/10" />
383
+ </div>
384
+ ) : (
385
+ <Wireframe blockType={blockType} />
386
+ )}
387
+ </div>
388
+ );
389
+ }
@@ -0,0 +1,233 @@
1
+ import { useEffect, useState, type MouseEvent } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { toast } from "sonner";
4
+ import { Crosshair, RotateCcw, X } from "lucide-react";
5
+
6
+ import {
7
+ clampFocal,
8
+ focalToObjectPosition,
9
+ isDefaultFocal,
10
+ lookupEntry,
11
+ lookupFocal,
12
+ type FocalPoint,
13
+ type FocusDevice,
14
+ type FocusMap,
15
+ } from "../../media/focus";
16
+
17
+ /**
18
+ * Backoffice focus-point editor. Click anywhere on the image to choose the
19
+ * part of it that must stay visible in every cropped placement.
20
+ * The host injects the current focus map and the persistence calls.
21
+ * Backoffice copy is English by project rule.
22
+ */
23
+ export function FocusPicker({
24
+ src,
25
+ onClose,
26
+ modal = false,
27
+ title,
28
+ focusMap,
29
+ saveFocus,
30
+ clearFocus,
31
+ onChanged,
32
+ }: {
33
+ src: string;
34
+ onClose?: () => void;
35
+ /** Render inside a centred overlay dialog. */
36
+ modal?: boolean;
37
+ /** Filename shown in the dialog header. */
38
+ title?: string;
39
+ focusMap: FocusMap | undefined;
40
+ saveFocus: (args: {
41
+ src: string;
42
+ x: number;
43
+ y: number;
44
+ device: FocusDevice;
45
+ }) => Promise<unknown> | unknown;
46
+ clearFocus: (args: { src: string; device: FocusDevice }) => Promise<unknown> | unknown;
47
+ /** Called after a successful save/reset so the host can invalidate caches. */
48
+ onChanged?: () => Promise<unknown> | unknown;
49
+ }) {
50
+ const map = focusMap;
51
+ const [device, setDevice] = useState<FocusDevice>("desktop");
52
+ const entry = lookupEntry(map, src);
53
+ const hasMobile = Boolean(entry.mobile);
54
+ const saved = lookupFocal(map, src, device);
55
+ const [point, setPoint] = useState<FocalPoint>(saved);
56
+
57
+ useEffect(() => {
58
+ setPoint(lookupFocal(map, src, device));
59
+ }, [src, device, map]);
60
+
61
+ useEffect(() => {
62
+ if (!modal) return;
63
+ const onKey = (e: KeyboardEvent) => {
64
+ if (e.key === "Escape") onClose?.();
65
+ };
66
+ window.addEventListener("keydown", onKey);
67
+ const prev = document.body.style.overflow;
68
+ document.body.style.overflow = "hidden";
69
+ return () => {
70
+ window.removeEventListener("keydown", onKey);
71
+ document.body.style.overflow = prev;
72
+ };
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [modal]);
75
+
76
+ const saveMutation = useMutation({
77
+ mutationFn: async () => saveFocus({ src, x: point.x, y: point.y, device }),
78
+ onSuccess: async () => {
79
+ await onChanged?.();
80
+ toast.success(device === "mobile" ? "Mobile focus point saved" : "Desktop focus point saved");
81
+ onClose?.();
82
+ },
83
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not save"),
84
+ });
85
+
86
+ const resetMutation = useMutation({
87
+ mutationFn: async () => clearFocus({ src, device }),
88
+ onSuccess: async () => {
89
+ setPoint({ x: 50, y: 50 });
90
+ await onChanged?.();
91
+ toast.success(
92
+ device === "mobile" ? "Mobile focus removed (uses desktop)" : "Focus point reset to centre",
93
+ );
94
+ },
95
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not reset"),
96
+ });
97
+
98
+ const pick = (e: MouseEvent<HTMLDivElement>) => {
99
+ const rect = e.currentTarget.getBoundingClientRect();
100
+ setPoint(
101
+ clampFocal({
102
+ x: ((e.clientX - rect.left) / rect.width) * 100,
103
+ y: ((e.clientY - rect.top) / rect.height) * 100,
104
+ }),
105
+ );
106
+ };
107
+
108
+ const objectPosition = focalToObjectPosition(point);
109
+
110
+ const body = (
111
+ <div className="space-y-3">
112
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Focus point</div>
113
+ <div className="flex items-center gap-1 border border-ink/15 p-1 w-fit">
114
+ {(["desktop", "mobile"] as FocusDevice[]).map((d) => (
115
+ <button
116
+ key={d}
117
+ type="button"
118
+ onClick={() => setDevice(d)}
119
+ className={`px-3 py-1 text-[0.65rem] uppercase tracking-widest ${
120
+ device === d ? "bg-ink text-paper" : "text-ink/60 hover:text-ink"
121
+ }`}
122
+ >
123
+ {d === "desktop" ? "Desktop" : "Mobile"}
124
+ {d === "mobile" && hasMobile ? " •" : ""}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ <p className="text-xs text-ink/55">
129
+ Click the image to choose what stays visible when it is cropped. Applies everywhere this
130
+ image is used.
131
+ {device === "mobile"
132
+ ? " Mobile focus applies below 768px; leave it unset to reuse the desktop point."
133
+ : ""}
134
+ </p>
135
+
136
+ <div
137
+ role="button"
138
+ tabIndex={0}
139
+ onClick={pick}
140
+ className="relative w-full cursor-crosshair border border-ink/15 bg-ice/30 select-none"
141
+ >
142
+ <img src={src} alt="" className="block w-full h-auto max-h-[46vh] object-contain mx-auto" />
143
+ <span
144
+ className="pointer-events-none absolute size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_2px_rgba(0,0,0,0.45)] bg-sea/70"
145
+ style={{ left: `${point.x}%`, top: `${point.y}%` }}
146
+ />
147
+ </div>
148
+
149
+ <div className="grid grid-cols-3 gap-2">
150
+ {[
151
+ { label: "Wide", cls: "aspect-[16/9]" },
152
+ { label: "Square", cls: "aspect-square" },
153
+ { label: "Tall", cls: "aspect-[3/4]" },
154
+ ].map((p) => (
155
+ <div key={p.label} className="space-y-1">
156
+ <div className={`overflow-hidden border border-ink/10 bg-ice/30 ${p.cls}`}>
157
+ <img
158
+ src={src}
159
+ alt=""
160
+ className="h-full w-full object-cover"
161
+ style={{ objectPosition }}
162
+ />
163
+ </div>
164
+ <div className="text-[0.6rem] uppercase tracking-widest text-ink/45">{p.label}</div>
165
+ </div>
166
+ ))}
167
+ </div>
168
+
169
+ <div className="flex flex-wrap items-center gap-2">
170
+ <button
171
+ type="button"
172
+ onClick={() => saveMutation.mutate()}
173
+ disabled={saveMutation.isPending}
174
+ className="inline-flex items-center gap-2 bg-ink px-3 py-1.5 text-xs uppercase tracking-widest text-paper disabled:opacity-50"
175
+ >
176
+ <Crosshair className="size-3.5" />
177
+ {saveMutation.isPending
178
+ ? "Saving…"
179
+ : device === "mobile"
180
+ ? "Save mobile focus"
181
+ : "Save focus"}
182
+ </button>
183
+ <button
184
+ type="button"
185
+ onClick={() => resetMutation.mutate()}
186
+ disabled={
187
+ resetMutation.isPending ||
188
+ (device === "mobile" ? !hasMobile : isDefaultFocal(point) && isDefaultFocal(saved))
189
+ }
190
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea disabled:opacity-40"
191
+ >
192
+ <RotateCcw className="size-3.5" />
193
+ {device === "mobile" ? "Remove mobile" : "Reset"}
194
+ </button>
195
+ <span className="text-[0.65rem] uppercase tracking-widest text-ink/45">
196
+ {point.x}% / {point.y}%
197
+ </span>
198
+ </div>
199
+ </div>
200
+ );
201
+
202
+ if (!modal) return body;
203
+
204
+ return (
205
+ <div
206
+ className="fixed inset-0 z-50 bg-ink/60 grid place-items-center p-6"
207
+ onClick={() => onClose?.()}
208
+ >
209
+ <div
210
+ className="w-full max-w-2xl max-h-[88vh] overflow-auto bg-white border border-ink/10 p-5 shadow-xl"
211
+ onClick={(e) => e.stopPropagation()}
212
+ >
213
+ <div className="mb-4 flex items-start justify-between gap-4">
214
+ <div className="min-w-0">
215
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
216
+ Set focus point
217
+ </div>
218
+ {title ? <div className="truncate text-sm text-ink/70">{title}</div> : null}
219
+ </div>
220
+ <button
221
+ type="button"
222
+ onClick={() => onClose?.()}
223
+ className="border border-ink/15 p-1 text-ink/60 hover:border-sea"
224
+ title="Close"
225
+ >
226
+ <X className="size-4" />
227
+ </button>
228
+ </div>
229
+ {body}
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -0,0 +1,211 @@
1
+ import { useEffect, useState, type ChangeEvent } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { toast } from "sonner";
4
+ import { Save, Trash2, X } from "lucide-react";
5
+
6
+ import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n/locales";
7
+ import {
8
+ hasMediaMeta,
9
+ lookupMediaMeta,
10
+ type LocalizedText,
11
+ type MediaMeta,
12
+ type MediaMetaMap,
13
+ } from "../../media/media-meta";
14
+
15
+ /**
16
+ * Backoffice editor for one asset's SEO metadata (alt / OG title /
17
+ * OG description / credit), each per language. The host injects the metadata
18
+ * map and the persistence calls.
19
+ * Backoffice copy is English by project rule.
20
+ */
21
+ export function MediaMetaDialog({
22
+ src,
23
+ title,
24
+ onClose,
25
+ metaMap,
26
+ saveMeta,
27
+ clearMeta,
28
+ onChanged,
29
+ }: {
30
+ src: string;
31
+ title?: string;
32
+ onClose: () => void;
33
+ metaMap: MediaMetaMap | undefined;
34
+ saveMeta: (args: { src: string } & MediaMeta) => Promise<unknown> | unknown;
35
+ clearMeta: (args: { src: string }) => Promise<unknown> | unknown;
36
+ /** Called after a successful save/remove so the host can invalidate caches. */
37
+ onChanged?: () => Promise<unknown> | unknown;
38
+ }) {
39
+ const map = metaMap;
40
+ const saved = lookupMediaMeta(map, src);
41
+ const [meta, setMeta] = useState<MediaMeta>(saved);
42
+
43
+ useEffect(() => {
44
+ setMeta(lookupMediaMeta(map, src));
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [src]);
47
+
48
+ useEffect(() => {
49
+ const onKey = (e: KeyboardEvent) => {
50
+ if (e.key === "Escape") onClose();
51
+ };
52
+ window.addEventListener("keydown", onKey);
53
+ const prev = document.body.style.overflow;
54
+ document.body.style.overflow = "hidden";
55
+ return () => {
56
+ window.removeEventListener("keydown", onKey);
57
+ document.body.style.overflow = prev;
58
+ };
59
+ }, [onClose]);
60
+
61
+ const saveMutation = useMutation({
62
+ mutationFn: async () => saveMeta({ src, ...meta }),
63
+ onSuccess: async () => {
64
+ await onChanged?.();
65
+ toast.success("Image metadata saved");
66
+ onClose();
67
+ },
68
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not save"),
69
+ });
70
+
71
+ const clearMutation = useMutation({
72
+ mutationFn: async () => clearMeta({ src }),
73
+ onSuccess: async () => {
74
+ setMeta({ alt: {}, title: {}, description: {}, credit: "" });
75
+ await onChanged?.();
76
+ toast.success("Image metadata removed");
77
+ },
78
+ onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not remove"),
79
+ });
80
+
81
+ const setField = (field: "alt" | "title" | "description", locale: Locale, value: string) =>
82
+ setMeta((m) => ({ ...m, [field]: { ...(m[field] as LocalizedText), [locale]: value } }));
83
+
84
+ return (
85
+ <div
86
+ className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/60 p-4"
87
+ onClick={onClose}
88
+ >
89
+ <div
90
+ className="w-full max-w-2xl max-h-[88vh] overflow-auto border border-ink/10 bg-white p-5 shadow-xl"
91
+ onClick={(e) => e.stopPropagation()}
92
+ >
93
+ <div className="mb-4 flex items-start justify-between gap-4">
94
+ <div className="min-w-0">
95
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
96
+ Image metadata (SEO)
97
+ </div>
98
+ {title ? <div className="truncate text-sm text-ink/70">{title}</div> : null}
99
+ </div>
100
+ <button
101
+ type="button"
102
+ onClick={onClose}
103
+ className="border border-ink/15 p-1 text-ink/60 hover:border-sea"
104
+ title="Close"
105
+ >
106
+ <X className="size-4" />
107
+ </button>
108
+ </div>
109
+
110
+ <div className="mb-4 flex items-start gap-4">
111
+ <img src={src} alt="" className="h-24 w-24 border border-ink/10 object-cover" />
112
+ <p className="text-xs text-ink/55">
113
+ Stored on the asset itself, so any page or block can import it: alt text, share (OG)
114
+ title and description. Set one value per language.
115
+ </p>
116
+ </div>
117
+
118
+ <div className="space-y-5">
119
+ <MetaGroup
120
+ label="Alt text"
121
+ hint="Describes the image for screen readers and search engines."
122
+ value={meta.alt}
123
+ onChange={(l, v) => setField("alt", l, v)}
124
+ />
125
+ <MetaGroup
126
+ label="OG / share title"
127
+ value={meta.title}
128
+ onChange={(l, v) => setField("title", l, v)}
129
+ />
130
+ <MetaGroup
131
+ label="OG / share description"
132
+ multiline
133
+ value={meta.description}
134
+ onChange={(l, v) => setField("description", l, v)}
135
+ />
136
+ <label className="block space-y-1.5">
137
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
138
+ Credit / photographer
139
+ </span>
140
+ <input
141
+ value={meta.credit}
142
+ onChange={(e) => setMeta((m) => ({ ...m, credit: e.target.value }))}
143
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:border-sea focus:outline-none"
144
+ />
145
+ </label>
146
+ </div>
147
+
148
+ <div className="mt-6 flex flex-wrap items-center gap-2">
149
+ <button
150
+ type="button"
151
+ onClick={() => saveMutation.mutate()}
152
+ disabled={saveMutation.isPending}
153
+ className="inline-flex items-center gap-2 bg-ink px-3 py-1.5 text-xs uppercase tracking-widest text-paper disabled:opacity-50"
154
+ >
155
+ <Save className="size-3.5" />
156
+ {saveMutation.isPending ? "Saving…" : "Save metadata"}
157
+ </button>
158
+ <button
159
+ type="button"
160
+ onClick={() => clearMutation.mutate()}
161
+ disabled={clearMutation.isPending || !hasMediaMeta(saved)}
162
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea disabled:opacity-40"
163
+ >
164
+ <Trash2 className="size-3.5" />
165
+ Remove
166
+ </button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function MetaGroup({
174
+ label,
175
+ hint,
176
+ value,
177
+ onChange,
178
+ multiline,
179
+ }: {
180
+ label: string;
181
+ hint?: string;
182
+ value: LocalizedText;
183
+ onChange: (locale: Locale, value: string) => void;
184
+ multiline?: boolean;
185
+ }) {
186
+ return (
187
+ <div className="space-y-1.5">
188
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</div>
189
+ {hint ? <p className="text-xs text-ink/50">{hint}</p> : null}
190
+ <div className="space-y-2">
191
+ {SUPPORTED_LOCALES.map((loc) => {
192
+ const props = {
193
+ value: value?.[loc] ?? "",
194
+ onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
195
+ onChange(loc, e.target.value),
196
+ className:
197
+ "w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:border-sea focus:outline-none",
198
+ };
199
+ return (
200
+ <div key={loc} className="flex items-start gap-2">
201
+ <span className="mt-1.5 w-8 shrink-0 text-[0.65rem] uppercase tracking-widest text-ink/45">
202
+ {LOCALE_LABELS[loc] ?? loc}
203
+ </span>
204
+ {multiline ? <textarea rows={2} {...props} /> : <input type="text" {...props} />}
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+ </div>
210
+ );
211
+ }