@bygghammar/cms-platform 0.8.0 → 0.9.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.9.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,10 @@
|
|
|
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"
|
|
88
91
|
},
|
|
89
92
|
"dependencies": {
|
|
90
93
|
"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
|
+
}
|
|
@@ -6,10 +6,15 @@ import type { FormFieldDef, FormPrivateSettings } from "../../forms/field-sets";
|
|
|
6
6
|
import { fieldLabel } from "../../forms/conditions";
|
|
7
7
|
|
|
8
8
|
export type TestNotificationResult = {
|
|
9
|
-
|
|
10
|
-
recipients
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
sent: boolean;
|
|
10
|
+
recipients: string[];
|
|
11
|
+
subject: string;
|
|
12
|
+
from: string;
|
|
13
|
+
replyTo: string;
|
|
14
|
+
matchedRules: { to: string; exclusive: boolean; label?: string }[];
|
|
15
|
+
sampleValues: Record<string, string>;
|
|
16
|
+
warnings: string[];
|
|
17
|
+
error?: string;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -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
|
+
}
|