@bygghammar/cms-platform 0.9.0 → 0.11.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.11.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,
|
|
@@ -87,7 +87,10 @@
|
|
|
87
87
|
"./backoffice/SaveGlobalDialog": "./src/backoffice/SaveGlobalDialog.tsx",
|
|
88
88
|
"./backoffice/BlockPicker": "./src/backoffice/BlockPicker.tsx",
|
|
89
89
|
"./backoffice/media/FocusPicker": "./src/backoffice/media/FocusPicker.tsx",
|
|
90
|
-
"./backoffice/media/MediaMetaDialog": "./src/backoffice/media/MediaMetaDialog.tsx"
|
|
90
|
+
"./backoffice/media/MediaMetaDialog": "./src/backoffice/media/MediaMetaDialog.tsx",
|
|
91
|
+
"./backoffice/SectionThumb": "./src/backoffice/SectionThumb.tsx",
|
|
92
|
+
"./backoffice/PageImagePicker": "./src/backoffice/PageImagePicker.tsx",
|
|
93
|
+
"./backoffice/seo/page-seo-form": "./src/backoffice/seo/page-seo-form.ts"
|
|
91
94
|
},
|
|
92
95
|
"dependencies": {
|
|
93
96
|
"fflate": "^0.8.3"
|
|
@@ -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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic form logic for the page SEO editor.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only — no React, no data access. A host wires these to its
|
|
5
|
+
* own page registry, CMS overrides and save/reset server functions so the SEO
|
|
6
|
+
* screen behaves identically on every site.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LocalizedField = Record<string, string>;
|
|
10
|
+
|
|
11
|
+
export type PageSeoFormShape = {
|
|
12
|
+
slugs: LocalizedField;
|
|
13
|
+
seo: {
|
|
14
|
+
title: LocalizedField;
|
|
15
|
+
description: LocalizedField;
|
|
16
|
+
ogTitle: LocalizedField;
|
|
17
|
+
ogDescription: LocalizedField;
|
|
18
|
+
keywords: LocalizedField;
|
|
19
|
+
ogImageSrc: string;
|
|
20
|
+
ogImageAlt: LocalizedField;
|
|
21
|
+
robots: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Shape shared by a registry page definition and a CMS override row. */
|
|
26
|
+
export type PageSeoSource = {
|
|
27
|
+
slug?: Partial<LocalizedField> | null;
|
|
28
|
+
seo?: {
|
|
29
|
+
title?: Partial<LocalizedField> | null;
|
|
30
|
+
description?: Partial<LocalizedField> | null;
|
|
31
|
+
ogTitle?: Partial<LocalizedField> | null;
|
|
32
|
+
ogDescription?: Partial<LocalizedField> | null;
|
|
33
|
+
keywords?: Partial<LocalizedField> | null;
|
|
34
|
+
ogImage?: { src?: string | null; alt?: Partial<LocalizedField> | null } | null;
|
|
35
|
+
robots?: string | null;
|
|
36
|
+
} | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Override rows store slugs under `slugs` rather than `slug`. */
|
|
40
|
+
export type PageSeoOverrideSource = Omit<PageSeoSource, "slug"> & {
|
|
41
|
+
slugs?: Partial<LocalizedField> | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type PageSeoSavePayload = {
|
|
45
|
+
pageKey: string;
|
|
46
|
+
slugs: LocalizedField;
|
|
47
|
+
seo: {
|
|
48
|
+
title?: LocalizedField;
|
|
49
|
+
description?: LocalizedField;
|
|
50
|
+
ogTitle?: LocalizedField;
|
|
51
|
+
ogDescription?: LocalizedField;
|
|
52
|
+
keywords?: LocalizedField;
|
|
53
|
+
ogImage?: { src: string; alt: LocalizedField };
|
|
54
|
+
robots?: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function emptyLocalized(locales: readonly string[]): LocalizedField {
|
|
59
|
+
const out: LocalizedField = {};
|
|
60
|
+
for (const l of locales) out[l] = "";
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop empty strings; returns undefined when nothing is left. */
|
|
65
|
+
export function stripEmptyLocalized(obj: LocalizedField): LocalizedField | undefined {
|
|
66
|
+
const out: LocalizedField = {};
|
|
67
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
68
|
+
if (v !== "" && v != null) out[k] = v;
|
|
69
|
+
}
|
|
70
|
+
return Object.keys(out).length ? out : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the initial form state: registry defaults first, then any CMS override
|
|
75
|
+
* value that is explicitly present (including a deliberate empty string).
|
|
76
|
+
*/
|
|
77
|
+
export function buildPageSeoForm(args: {
|
|
78
|
+
locales: readonly string[];
|
|
79
|
+
def?: PageSeoSource | null;
|
|
80
|
+
override?: PageSeoOverrideSource | null;
|
|
81
|
+
}): PageSeoFormShape {
|
|
82
|
+
const { locales, def, override } = args;
|
|
83
|
+
const form: PageSeoFormShape = {
|
|
84
|
+
slugs: emptyLocalized(locales),
|
|
85
|
+
seo: {
|
|
86
|
+
title: emptyLocalized(locales),
|
|
87
|
+
description: emptyLocalized(locales),
|
|
88
|
+
ogTitle: emptyLocalized(locales),
|
|
89
|
+
ogDescription: emptyLocalized(locales),
|
|
90
|
+
keywords: emptyLocalized(locales),
|
|
91
|
+
ogImageSrc: "",
|
|
92
|
+
ogImageAlt: emptyLocalized(locales),
|
|
93
|
+
robots: "",
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (def) {
|
|
98
|
+
for (const l of locales) {
|
|
99
|
+
form.slugs[l] = def.slug?.[l] ?? "";
|
|
100
|
+
form.seo.title[l] = def.seo?.title?.[l] ?? "";
|
|
101
|
+
form.seo.description[l] = def.seo?.description?.[l] ?? "";
|
|
102
|
+
form.seo.ogTitle[l] = def.seo?.ogTitle?.[l] ?? "";
|
|
103
|
+
form.seo.ogDescription[l] = def.seo?.ogDescription?.[l] ?? "";
|
|
104
|
+
form.seo.keywords[l] = def.seo?.keywords?.[l] ?? "";
|
|
105
|
+
form.seo.ogImageAlt[l] = def.seo?.ogImage?.alt?.[l] ?? "";
|
|
106
|
+
}
|
|
107
|
+
form.seo.ogImageSrc = def.seo?.ogImage?.src ?? "";
|
|
108
|
+
form.seo.robots = def.seo?.robots ?? "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (override) {
|
|
112
|
+
for (const l of locales) {
|
|
113
|
+
if (override.slugs?.[l] !== undefined) form.slugs[l] = override.slugs[l] ?? "";
|
|
114
|
+
if (override.seo?.title?.[l] !== undefined) form.seo.title[l] = override.seo.title[l] ?? "";
|
|
115
|
+
if (override.seo?.description?.[l] !== undefined)
|
|
116
|
+
form.seo.description[l] = override.seo.description[l] ?? "";
|
|
117
|
+
if (override.seo?.ogTitle?.[l] !== undefined)
|
|
118
|
+
form.seo.ogTitle[l] = override.seo.ogTitle[l] ?? "";
|
|
119
|
+
if (override.seo?.ogDescription?.[l] !== undefined)
|
|
120
|
+
form.seo.ogDescription[l] = override.seo.ogDescription[l] ?? "";
|
|
121
|
+
if (override.seo?.keywords?.[l] !== undefined)
|
|
122
|
+
form.seo.keywords[l] = override.seo.keywords[l] ?? "";
|
|
123
|
+
if (override.seo?.ogImage?.alt?.[l] !== undefined)
|
|
124
|
+
form.seo.ogImageAlt[l] = override.seo.ogImage.alt[l] ?? "";
|
|
125
|
+
}
|
|
126
|
+
if (override.seo?.ogImage?.src) form.seo.ogImageSrc = override.seo.ogImage.src;
|
|
127
|
+
if (override.seo?.robots !== undefined) form.seo.robots = override.seo.robots ?? "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return form;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Build the save payload, omitting fields the editor left empty. */
|
|
134
|
+
export function buildPageSeoSavePayload(args: {
|
|
135
|
+
pageKey: string;
|
|
136
|
+
locales: readonly string[];
|
|
137
|
+
form: PageSeoFormShape;
|
|
138
|
+
}): PageSeoSavePayload {
|
|
139
|
+
const { pageKey, locales, form } = args;
|
|
140
|
+
const payload: PageSeoSavePayload = { pageKey, slugs: {}, seo: {} };
|
|
141
|
+
|
|
142
|
+
for (const l of locales) {
|
|
143
|
+
if (form.slugs[l] !== undefined) payload.slugs[l] = form.slugs[l];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const title = stripEmptyLocalized(form.seo.title);
|
|
147
|
+
const description = stripEmptyLocalized(form.seo.description);
|
|
148
|
+
const ogTitle = stripEmptyLocalized(form.seo.ogTitle);
|
|
149
|
+
const ogDescription = stripEmptyLocalized(form.seo.ogDescription);
|
|
150
|
+
const keywords = stripEmptyLocalized(form.seo.keywords);
|
|
151
|
+
const ogImageAlt = stripEmptyLocalized(form.seo.ogImageAlt);
|
|
152
|
+
|
|
153
|
+
if (title) payload.seo.title = title;
|
|
154
|
+
if (description) payload.seo.description = description;
|
|
155
|
+
if (ogTitle) payload.seo.ogTitle = ogTitle;
|
|
156
|
+
if (ogDescription) payload.seo.ogDescription = ogDescription;
|
|
157
|
+
if (keywords) payload.seo.keywords = keywords;
|
|
158
|
+
if (form.seo.robots) payload.seo.robots = form.seo.robots;
|
|
159
|
+
if (form.seo.ogImageSrc && ogImageAlt) {
|
|
160
|
+
payload.seo.ogImage = { src: form.seo.ogImageSrc, alt: ogImageAlt };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return payload;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Merge media-library metadata (alt / OG title / description) into the form.
|
|
168
|
+
* `overwrite` replaces values the editor already typed.
|
|
169
|
+
*/
|
|
170
|
+
export function mergeMediaMetaIntoForm(args: {
|
|
171
|
+
form: PageSeoFormShape;
|
|
172
|
+
locales: readonly string[];
|
|
173
|
+
meta: {
|
|
174
|
+
alt?: Partial<LocalizedField> | null;
|
|
175
|
+
title?: Partial<LocalizedField> | null;
|
|
176
|
+
description?: Partial<LocalizedField> | null;
|
|
177
|
+
};
|
|
178
|
+
overwrite: boolean;
|
|
179
|
+
}): PageSeoFormShape {
|
|
180
|
+
const { form, locales, meta, overwrite } = args;
|
|
181
|
+
const merge = (current: LocalizedField, incoming?: Partial<LocalizedField> | null) => {
|
|
182
|
+
const next = { ...current };
|
|
183
|
+
if (!incoming) return next;
|
|
184
|
+
for (const l of locales) {
|
|
185
|
+
const value = incoming[l];
|
|
186
|
+
if (!value) continue;
|
|
187
|
+
if (overwrite || !next[l]) next[l] = value;
|
|
188
|
+
}
|
|
189
|
+
return next;
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
...form,
|
|
193
|
+
seo: {
|
|
194
|
+
...form.seo,
|
|
195
|
+
ogImageAlt: merge(form.seo.ogImageAlt, meta.alt),
|
|
196
|
+
ogTitle: merge(form.seo.ogTitle, meta.title),
|
|
197
|
+
ogDescription: merge(form.seo.ogDescription, meta.description),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Locale-aware preview path: default locale has no prefix, empty slug = root. */
|
|
203
|
+
export function pageSeoPreviewPath(args: {
|
|
204
|
+
slug: string;
|
|
205
|
+
locale: string;
|
|
206
|
+
defaultLocale: string;
|
|
207
|
+
}): string {
|
|
208
|
+
const slug = args.slug ?? "";
|
|
209
|
+
if (args.locale === args.defaultLocale) return slug === "" ? "/" : `/${slug}`;
|
|
210
|
+
return slug === "" ? `/${args.locale}` : `/${args.locale}/${slug}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Absolute canonical preview URL; falls back to the path when no domain is known. */
|
|
214
|
+
export function pageSeoPreviewCanonical(args: { path: string; origin?: string | null }): string {
|
|
215
|
+
return args.origin ? `https://${args.origin}${args.path}` : args.path;
|
|
216
|
+
}
|