@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.
|
|
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
|
+
}
|