@bygghammar/cms-platform 0.1.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/README.md +36 -0
- package/package.json +39 -0
- package/src/backoffice/inspectors/blocks.tsx +2318 -0
- package/src/backoffice/inspectors/fields.tsx +436 -0
- package/src/blocks/block-props-context.tsx +36 -0
- package/src/blocks/components/CabinTabsBlock.tsx +167 -0
- package/src/blocks/components/ChecklistBlock.tsx +68 -0
- package/src/blocks/components/CtaBannerBlock.tsx +65 -0
- package/src/blocks/components/FaqBlock.tsx +62 -0
- package/src/blocks/components/HeroFullBlock.tsx +201 -0
- package/src/blocks/components/IconGridBlock.tsx +125 -0
- package/src/blocks/components/ImageMarqueeBlock.tsx +209 -0
- package/src/blocks/components/InstagramBlock.tsx +160 -0
- package/src/blocks/components/LinkTilesBlock.tsx +182 -0
- package/src/blocks/components/MediaSplitBlock.tsx +427 -0
- package/src/blocks/components/PriceTableBlock.tsx +166 -0
- package/src/blocks/components/ProseBlock.tsx +77 -0
- package/src/blocks/components/RichTextBlock.tsx +79 -0
- package/src/blocks/icons.ts +63 -0
- package/src/blocks/index.ts +13 -0
- package/src/blocks/registry.tsx +322 -0
- package/src/blocks/types.ts +470 -0
- package/src/host/index.tsx +134 -0
- package/src/html/sanitize-html.ts +175 -0
- package/src/i18n/context.tsx +44 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/locales.ts +24 -0
- package/src/i18n/resolve.ts +82 -0
- package/src/i18n/translations-registry.ts +26 -0
- package/src/i18n/types.ts +22 -0
- package/src/i18n/video.ts +36 -0
- package/src/index.ts +5 -0
- package/src/theme.css +53 -0
- package/src/ui/Lightbox.tsx +69 -0
- package/src/ui/Reveal.tsx +47 -0
- package/src/util/scroll-lock.ts +32 -0
|
@@ -0,0 +1,2318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared block inspectors (backoffice). Portable across sites: they depend
|
|
3
|
+
* only on block types, shared field widgets, the i18n core and the host
|
|
4
|
+
* RichTextEditor. Site-specific inspectors (global sections, forms, courses)
|
|
5
|
+
* stay in each site's own inspector router.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useMemo, useState } from "react";
|
|
8
|
+
import type {
|
|
9
|
+
BlockInstance,
|
|
10
|
+
BlockProps,
|
|
11
|
+
HeroProps,
|
|
12
|
+
IntroProps,
|
|
13
|
+
FaqProps,
|
|
14
|
+
FaqItem,
|
|
15
|
+
CtaProps,
|
|
16
|
+
InstagramProps,
|
|
17
|
+
InstagramItem,
|
|
18
|
+
ProseProps,
|
|
19
|
+
ProseItem,
|
|
20
|
+
RichTextProps,
|
|
21
|
+
ChecklistProps,
|
|
22
|
+
ChecklistColumn,
|
|
23
|
+
CtaBannerProps,
|
|
24
|
+
MediaSplitProps,
|
|
25
|
+
MediaSplitSlide,
|
|
26
|
+
MediaSplitBullet,
|
|
27
|
+
IconGridProps,
|
|
28
|
+
IconGridItem,
|
|
29
|
+
HeroFullProps,
|
|
30
|
+
ImageMarqueeProps,
|
|
31
|
+
ImageMarqueeItem,
|
|
32
|
+
LinkTilesProps,
|
|
33
|
+
LinkTilesItem,
|
|
34
|
+
CabinTabsProps,
|
|
35
|
+
CabinTabItem,
|
|
36
|
+
PriceTableProps,
|
|
37
|
+
PriceGroup,
|
|
38
|
+
IntroImageAspect,
|
|
39
|
+
} from "../../blocks/types";
|
|
40
|
+
import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
|
|
41
|
+
import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
|
|
42
|
+
import { InspectorSection, LocalizedField, TextField, MediaField, MediaMultiField } from "./fields";
|
|
43
|
+
import { RichTextEditor } from "../../host";
|
|
44
|
+
import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
|
|
45
|
+
import type { LocalizedString, Locale } from "../../i18n";
|
|
46
|
+
import { SUPPORTED_LOCALES } from "../../i18n";
|
|
47
|
+
|
|
48
|
+
export type BlockPatchProps = {
|
|
49
|
+
block: BlockInstance;
|
|
50
|
+
patch: (props: BlockProps) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function uid(prefix = "f") {
|
|
54
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
|
55
|
+
}
|
|
56
|
+
/* ---------------- Hero ---------------- */
|
|
57
|
+
function HeroInspector({ block, patch }: BlockPatchProps) {
|
|
58
|
+
const p = (block.props ?? {}) as HeroProps;
|
|
59
|
+
const setCta = (key: "primaryCta" | "secondaryCta", next: CtaProps) =>
|
|
60
|
+
patch({ ...p, [key]: next } as unknown as BlockProps);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<InspectorSection title="Content">
|
|
65
|
+
<LocalizedField
|
|
66
|
+
label="Eyebrow"
|
|
67
|
+
value={p.eyebrow}
|
|
68
|
+
onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
|
|
69
|
+
/>
|
|
70
|
+
<LocalizedField
|
|
71
|
+
label="Title"
|
|
72
|
+
value={p.title}
|
|
73
|
+
multiline
|
|
74
|
+
onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
|
|
75
|
+
/>
|
|
76
|
+
<LocalizedField
|
|
77
|
+
label="Body"
|
|
78
|
+
value={p.body}
|
|
79
|
+
multiline
|
|
80
|
+
onChange={(v) => patch({ ...p, body: v } as unknown as BlockProps)}
|
|
81
|
+
/>
|
|
82
|
+
</InspectorSection>
|
|
83
|
+
<InspectorSection title="Primary CTA">
|
|
84
|
+
<LocalizedField
|
|
85
|
+
label="Label"
|
|
86
|
+
value={p.primaryCta?.label}
|
|
87
|
+
onChange={(v) => setCta("primaryCta", { ...(p.primaryCta ?? {}), label: v })}
|
|
88
|
+
/>
|
|
89
|
+
<TextField
|
|
90
|
+
label="Link"
|
|
91
|
+
value={p.primaryCta?.href}
|
|
92
|
+
placeholder="/boka"
|
|
93
|
+
onChange={(v) => setCta("primaryCta", { ...(p.primaryCta ?? {}), href: v })}
|
|
94
|
+
/>
|
|
95
|
+
</InspectorSection>
|
|
96
|
+
<InspectorSection title="Secondary CTA">
|
|
97
|
+
<LocalizedField
|
|
98
|
+
label="Label"
|
|
99
|
+
value={p.secondaryCta?.label}
|
|
100
|
+
onChange={(v) => setCta("secondaryCta", { ...(p.secondaryCta ?? {}), label: v })}
|
|
101
|
+
/>
|
|
102
|
+
<TextField
|
|
103
|
+
label="Link"
|
|
104
|
+
value={p.secondaryCta?.href}
|
|
105
|
+
placeholder="/utbildningen"
|
|
106
|
+
onChange={(v) => setCta("secondaryCta", { ...(p.secondaryCta ?? {}), href: v })}
|
|
107
|
+
/>
|
|
108
|
+
</InspectorSection>
|
|
109
|
+
<InspectorSection title="Background media — Desktop">
|
|
110
|
+
<MediaField
|
|
111
|
+
label="Video"
|
|
112
|
+
accept="video"
|
|
113
|
+
value={p.videoSrc}
|
|
114
|
+
onChange={(v) => patch({ ...p, videoSrc: v } as unknown as BlockProps)}
|
|
115
|
+
/>
|
|
116
|
+
<MediaField
|
|
117
|
+
label="Poster image"
|
|
118
|
+
accept="image"
|
|
119
|
+
value={p.posterSrc}
|
|
120
|
+
onChange={(v) => patch({ ...p, posterSrc: v } as unknown as BlockProps)}
|
|
121
|
+
/>
|
|
122
|
+
</InspectorSection>
|
|
123
|
+
<InspectorSection title="Background media — Tablet (≤1024px)">
|
|
124
|
+
<p className="text-[0.7rem] text-ink/50">Leave empty to reuse the desktop media.</p>
|
|
125
|
+
<MediaField
|
|
126
|
+
label="Video"
|
|
127
|
+
accept="video"
|
|
128
|
+
value={p.videoSrcTablet}
|
|
129
|
+
onChange={(v) => patch({ ...p, videoSrcTablet: v } as unknown as BlockProps)}
|
|
130
|
+
/>
|
|
131
|
+
<MediaField
|
|
132
|
+
label="Poster image"
|
|
133
|
+
accept="image"
|
|
134
|
+
value={p.posterSrcTablet}
|
|
135
|
+
onChange={(v) => patch({ ...p, posterSrcTablet: v } as unknown as BlockProps)}
|
|
136
|
+
/>
|
|
137
|
+
</InspectorSection>
|
|
138
|
+
<InspectorSection title="Background media — Mobile (≤640px)">
|
|
139
|
+
<p className="text-[0.7rem] text-ink/50">Leave empty to reuse tablet, then desktop.</p>
|
|
140
|
+
<MediaField
|
|
141
|
+
label="Video"
|
|
142
|
+
accept="video"
|
|
143
|
+
value={p.videoSrcMobile}
|
|
144
|
+
onChange={(v) => patch({ ...p, videoSrcMobile: v } as unknown as BlockProps)}
|
|
145
|
+
/>
|
|
146
|
+
<MediaField
|
|
147
|
+
label="Poster image"
|
|
148
|
+
accept="image"
|
|
149
|
+
value={p.posterSrcMobile}
|
|
150
|
+
onChange={(v) => patch({ ...p, posterSrcMobile: v } as unknown as BlockProps)}
|
|
151
|
+
/>
|
|
152
|
+
</InspectorSection>
|
|
153
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
154
|
+
Leaving a field empty falls back to the shared site content.
|
|
155
|
+
</p>
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ---------------- Intro ---------------- */
|
|
161
|
+
function IntroInspector({ block, patch }: BlockPatchProps) {
|
|
162
|
+
const p = (block.props ?? {}) as IntroProps;
|
|
163
|
+
return (
|
|
164
|
+
<InspectorSection title="Content">
|
|
165
|
+
<LocalizedField
|
|
166
|
+
label="Eyebrow"
|
|
167
|
+
value={p.eyebrow}
|
|
168
|
+
onChange={(v) => patch({ ...p, eyebrow: v } as unknown as BlockProps)}
|
|
169
|
+
/>
|
|
170
|
+
<LocalizedField
|
|
171
|
+
label="Title"
|
|
172
|
+
value={p.title}
|
|
173
|
+
multiline
|
|
174
|
+
onChange={(v) => patch({ ...p, title: v } as unknown as BlockProps)}
|
|
175
|
+
/>
|
|
176
|
+
<LocalizedField
|
|
177
|
+
label="Body"
|
|
178
|
+
value={p.body}
|
|
179
|
+
multiline
|
|
180
|
+
onChange={(v) => patch({ ...p, body: v } as unknown as BlockProps)}
|
|
181
|
+
/>
|
|
182
|
+
<MediaField
|
|
183
|
+
label="Image"
|
|
184
|
+
accept="image"
|
|
185
|
+
value={p.imageSrc}
|
|
186
|
+
onChange={(v) => patch({ ...p, imageSrc: v } as unknown as BlockProps)}
|
|
187
|
+
/>
|
|
188
|
+
<LocalizedField
|
|
189
|
+
label="Image alt text"
|
|
190
|
+
value={p.imageAlt}
|
|
191
|
+
onChange={(v) => patch({ ...p, imageAlt: v } as unknown as BlockProps)}
|
|
192
|
+
/>
|
|
193
|
+
<label className="block space-y-1">
|
|
194
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
195
|
+
Image shape (width / height)
|
|
196
|
+
</span>
|
|
197
|
+
<select
|
|
198
|
+
value={p.imageAspect ?? "4/5"}
|
|
199
|
+
onChange={(e) =>
|
|
200
|
+
patch({ ...p, imageAspect: e.target.value as IntroImageAspect } as unknown as BlockProps)
|
|
201
|
+
}
|
|
202
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
203
|
+
>
|
|
204
|
+
{INTRO_IMAGE_ASPECTS.map((o) => (
|
|
205
|
+
<option key={o} value={o}>
|
|
206
|
+
{o === "auto" ? "Original ratio" : o}
|
|
207
|
+
</option>
|
|
208
|
+
))}
|
|
209
|
+
</select>
|
|
210
|
+
</label>
|
|
211
|
+
<label className="block space-y-1">
|
|
212
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
213
|
+
Image width ({Math.min(100, Math.max(30, p.imageWidth ?? 100))}%)
|
|
214
|
+
</span>
|
|
215
|
+
<input
|
|
216
|
+
type="range"
|
|
217
|
+
min={30}
|
|
218
|
+
max={100}
|
|
219
|
+
step={5}
|
|
220
|
+
value={Math.min(100, Math.max(30, p.imageWidth ?? 100))}
|
|
221
|
+
onChange={(e) =>
|
|
222
|
+
patch({ ...p, imageWidth: Number(e.target.value) } as unknown as BlockProps)
|
|
223
|
+
}
|
|
224
|
+
className="w-full accent-sea"
|
|
225
|
+
/>
|
|
226
|
+
</label>
|
|
227
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
228
|
+
Empty fields fall back to shared site content.
|
|
229
|
+
</p>
|
|
230
|
+
|
|
231
|
+
</InspectorSection>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ---------------- FAQ ---------------- */
|
|
236
|
+
function FaqInspector({ block, patch }: BlockPatchProps) {
|
|
237
|
+
const p = (block.props ?? { items: [] }) as FaqProps;
|
|
238
|
+
const items = p.items ?? [];
|
|
239
|
+
const update = (next: FaqProps) => patch(next as unknown as BlockProps);
|
|
240
|
+
const setItem = (i: number, item: FaqItem) => {
|
|
241
|
+
const next = items.slice();
|
|
242
|
+
next[i] = item;
|
|
243
|
+
update({ ...p, items: next });
|
|
244
|
+
};
|
|
245
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
246
|
+
const j = i + dir;
|
|
247
|
+
if (j < 0 || j >= items.length) return;
|
|
248
|
+
const next = items.slice();
|
|
249
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
250
|
+
update({ ...p, items: next });
|
|
251
|
+
};
|
|
252
|
+
const add = () =>
|
|
253
|
+
update({
|
|
254
|
+
...p,
|
|
255
|
+
items: [
|
|
256
|
+
...items,
|
|
257
|
+
{ id: uid("q"), q: { sv: "" } as LocalizedString, a: { sv: "" } as LocalizedString },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
const remove = (i: number) => {
|
|
261
|
+
const next = items.slice();
|
|
262
|
+
next.splice(i, 1);
|
|
263
|
+
update({ ...p, items: next });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<>
|
|
268
|
+
<InspectorSection title="Heading">
|
|
269
|
+
<LocalizedField
|
|
270
|
+
label="Title"
|
|
271
|
+
value={p.title}
|
|
272
|
+
onChange={(v) => update({ ...p, title: v as LocalizedString })}
|
|
273
|
+
/>
|
|
274
|
+
<label className="block space-y-1.5">
|
|
275
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
276
|
+
Columns (desktop)
|
|
277
|
+
</span>
|
|
278
|
+
<select
|
|
279
|
+
value={String(p.columns ?? 1)}
|
|
280
|
+
onChange={(e) =>
|
|
281
|
+
update({ ...p, columns: Number(e.target.value) as FaqProps["columns"] })
|
|
282
|
+
}
|
|
283
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
284
|
+
>
|
|
285
|
+
<option value="1">1 column</option>
|
|
286
|
+
<option value="2">2 columns</option>
|
|
287
|
+
<option value="3">3 columns</option>
|
|
288
|
+
</select>
|
|
289
|
+
</label>
|
|
290
|
+
</InspectorSection>
|
|
291
|
+
<InspectorSection title={`Items (${items.length})`}>
|
|
292
|
+
<div className="space-y-4">
|
|
293
|
+
{items.map((item, i) => (
|
|
294
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
|
|
295
|
+
<div className="flex items-center justify-between">
|
|
296
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
297
|
+
#{i + 1}
|
|
298
|
+
</span>
|
|
299
|
+
<div className="flex items-center gap-1">
|
|
300
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
301
|
+
<ArrowUp className="size-3.5" />
|
|
302
|
+
</button>
|
|
303
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
304
|
+
<ArrowDown className="size-3.5" />
|
|
305
|
+
</button>
|
|
306
|
+
<button onClick={() => remove(i)} className="p-1 text-red-500 hover:text-red-700" title="Delete">
|
|
307
|
+
<Trash2 className="size-3.5" />
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
<LocalizedField
|
|
312
|
+
label="Question"
|
|
313
|
+
value={item.q}
|
|
314
|
+
onChange={(v) => setItem(i, { ...item, q: v as LocalizedString })}
|
|
315
|
+
/>
|
|
316
|
+
<LocalizedField
|
|
317
|
+
label="Answer"
|
|
318
|
+
multiline
|
|
319
|
+
value={item.a}
|
|
320
|
+
onChange={(v) => setItem(i, { ...item, a: v as LocalizedString })}
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
<button
|
|
326
|
+
onClick={add}
|
|
327
|
+
className="mt-1 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
328
|
+
>
|
|
329
|
+
<Plus className="size-4" /> Add question
|
|
330
|
+
</button>
|
|
331
|
+
</InspectorSection>
|
|
332
|
+
</>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ---------------- Instagram ---------------- */
|
|
337
|
+
function InstagramInspector({ block, patch }: BlockPatchProps) {
|
|
338
|
+
const p = (block.props ?? { items: [] }) as InstagramProps;
|
|
339
|
+
const items = p.items ?? [];
|
|
340
|
+
const update = (next: InstagramProps) => patch(next as unknown as BlockProps);
|
|
341
|
+
const setItem = (i: number, item: InstagramItem) => {
|
|
342
|
+
const next = items.slice();
|
|
343
|
+
next[i] = item;
|
|
344
|
+
update({ ...p, items: next });
|
|
345
|
+
};
|
|
346
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
347
|
+
const j = i + dir;
|
|
348
|
+
if (j < 0 || j >= items.length) return;
|
|
349
|
+
const next = items.slice();
|
|
350
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
351
|
+
update({ ...p, items: next });
|
|
352
|
+
};
|
|
353
|
+
const add = () =>
|
|
354
|
+
update({
|
|
355
|
+
...p,
|
|
356
|
+
items: [...items, { id: uid("ig"), image: "", alt: { sv: "" }, href: "" }],
|
|
357
|
+
});
|
|
358
|
+
const remove = (i: number) => {
|
|
359
|
+
const next = items.slice();
|
|
360
|
+
next.splice(i, 1);
|
|
361
|
+
update({ ...p, items: next });
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const NumberField = ({
|
|
365
|
+
label,
|
|
366
|
+
value,
|
|
367
|
+
min,
|
|
368
|
+
max,
|
|
369
|
+
fallback,
|
|
370
|
+
onChange,
|
|
371
|
+
}: {
|
|
372
|
+
label: string;
|
|
373
|
+
value: number | undefined;
|
|
374
|
+
min: number;
|
|
375
|
+
max: number;
|
|
376
|
+
fallback: number;
|
|
377
|
+
onChange: (n: number) => void;
|
|
378
|
+
}) => (
|
|
379
|
+
<label className="block space-y-1">
|
|
380
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
381
|
+
<input
|
|
382
|
+
type="number"
|
|
383
|
+
min={min}
|
|
384
|
+
max={max}
|
|
385
|
+
value={value ?? fallback}
|
|
386
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
387
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
388
|
+
/>
|
|
389
|
+
</label>
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const SelectField = <T extends string>({
|
|
393
|
+
label,
|
|
394
|
+
value,
|
|
395
|
+
options,
|
|
396
|
+
onChange,
|
|
397
|
+
}: {
|
|
398
|
+
label: string;
|
|
399
|
+
value: T | undefined;
|
|
400
|
+
options: readonly T[];
|
|
401
|
+
onChange: (v: T) => void;
|
|
402
|
+
}) => (
|
|
403
|
+
<label className="block space-y-1">
|
|
404
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
405
|
+
<select
|
|
406
|
+
value={value ?? options[0]}
|
|
407
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
408
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
409
|
+
>
|
|
410
|
+
{options.map((o) => (
|
|
411
|
+
<option key={o} value={o}>
|
|
412
|
+
{o}
|
|
413
|
+
</option>
|
|
414
|
+
))}
|
|
415
|
+
</select>
|
|
416
|
+
</label>
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<>
|
|
421
|
+
<InspectorSection title="Header">
|
|
422
|
+
<LocalizedField
|
|
423
|
+
label="Title"
|
|
424
|
+
value={p.title}
|
|
425
|
+
onChange={(v) => update({ ...p, title: v })}
|
|
426
|
+
/>
|
|
427
|
+
<TextField
|
|
428
|
+
label="Instagram handle"
|
|
429
|
+
value={p.handle}
|
|
430
|
+
placeholder="yourhandle"
|
|
431
|
+
onChange={(v) => update({ ...p, handle: v })}
|
|
432
|
+
/>
|
|
433
|
+
<TextField
|
|
434
|
+
label="Profile link (optional)"
|
|
435
|
+
value={p.profileHref}
|
|
436
|
+
placeholder="https://instagram.com/..."
|
|
437
|
+
onChange={(v) => update({ ...p, profileHref: v })}
|
|
438
|
+
/>
|
|
439
|
+
<div className="flex gap-4 pt-1">
|
|
440
|
+
<label className="flex items-center gap-2 text-sm text-ink/75">
|
|
441
|
+
<input
|
|
442
|
+
type="checkbox"
|
|
443
|
+
checked={p.showHeader ?? true}
|
|
444
|
+
onChange={(e) => update({ ...p, showHeader: e.target.checked })}
|
|
445
|
+
/>
|
|
446
|
+
Show header
|
|
447
|
+
</label>
|
|
448
|
+
<label className="flex items-center gap-2 text-sm text-ink/75">
|
|
449
|
+
<input
|
|
450
|
+
type="checkbox"
|
|
451
|
+
checked={p.showOverlay ?? true}
|
|
452
|
+
onChange={(e) => update({ ...p, showOverlay: e.target.checked })}
|
|
453
|
+
/>
|
|
454
|
+
Hover overlay
|
|
455
|
+
</label>
|
|
456
|
+
</div>
|
|
457
|
+
</InspectorSection>
|
|
458
|
+
|
|
459
|
+
<InspectorSection title="Layout">
|
|
460
|
+
<SelectField
|
|
461
|
+
label="Layout"
|
|
462
|
+
value={p.layout}
|
|
463
|
+
options={["grid", "carousel"] as const}
|
|
464
|
+
onChange={(v) => update({ ...p, layout: v })}
|
|
465
|
+
/>
|
|
466
|
+
<div className="grid grid-cols-3 gap-2">
|
|
467
|
+
<NumberField
|
|
468
|
+
label="Cols mobile"
|
|
469
|
+
value={p.columnsMobile}
|
|
470
|
+
min={1}
|
|
471
|
+
max={4}
|
|
472
|
+
fallback={2}
|
|
473
|
+
onChange={(n) => update({ ...p, columnsMobile: n })}
|
|
474
|
+
/>
|
|
475
|
+
<NumberField
|
|
476
|
+
label="Cols tablet"
|
|
477
|
+
value={p.columnsTablet}
|
|
478
|
+
min={2}
|
|
479
|
+
max={6}
|
|
480
|
+
fallback={3}
|
|
481
|
+
onChange={(n) => update({ ...p, columnsTablet: n })}
|
|
482
|
+
/>
|
|
483
|
+
<NumberField
|
|
484
|
+
label="Cols desktop"
|
|
485
|
+
value={p.columnsDesktop}
|
|
486
|
+
min={2}
|
|
487
|
+
max={8}
|
|
488
|
+
fallback={4}
|
|
489
|
+
onChange={(n) => update({ ...p, columnsDesktop: n })}
|
|
490
|
+
/>
|
|
491
|
+
</div>
|
|
492
|
+
<NumberField
|
|
493
|
+
label="Rows (grid only)"
|
|
494
|
+
value={p.rows}
|
|
495
|
+
min={1}
|
|
496
|
+
max={8}
|
|
497
|
+
fallback={2}
|
|
498
|
+
onChange={(n) => update({ ...p, rows: n })}
|
|
499
|
+
/>
|
|
500
|
+
<div className="grid grid-cols-2 gap-2">
|
|
501
|
+
<SelectField
|
|
502
|
+
label="Gap"
|
|
503
|
+
value={p.gap}
|
|
504
|
+
options={["none", "sm", "md", "lg"] as const}
|
|
505
|
+
onChange={(v) => update({ ...p, gap: v })}
|
|
506
|
+
/>
|
|
507
|
+
<SelectField
|
|
508
|
+
label="Aspect"
|
|
509
|
+
value={p.aspect}
|
|
510
|
+
options={["square", "portrait", "landscape"] as const}
|
|
511
|
+
onChange={(v) => update({ ...p, aspect: v })}
|
|
512
|
+
/>
|
|
513
|
+
</div>
|
|
514
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
515
|
+
Grid shows up to <strong>cols × rows</strong> images. Carousel shows all.
|
|
516
|
+
</p>
|
|
517
|
+
</InspectorSection>
|
|
518
|
+
|
|
519
|
+
<InspectorSection title={`Images (${items.length})`}>
|
|
520
|
+
<div className="space-y-4">
|
|
521
|
+
{items.map((item, i) => (
|
|
522
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
|
|
523
|
+
<div className="flex items-center justify-between">
|
|
524
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
525
|
+
#{i + 1}
|
|
526
|
+
</span>
|
|
527
|
+
<div className="flex items-center gap-1">
|
|
528
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
529
|
+
<ArrowUp className="size-3.5" />
|
|
530
|
+
</button>
|
|
531
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
532
|
+
<ArrowDown className="size-3.5" />
|
|
533
|
+
</button>
|
|
534
|
+
<button onClick={() => remove(i)} className="p-1 text-red-500 hover:text-red-700" title="Delete">
|
|
535
|
+
<Trash2 className="size-3.5" />
|
|
536
|
+
</button>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
<MediaField
|
|
540
|
+
label="Image"
|
|
541
|
+
accept="image"
|
|
542
|
+
value={item.image}
|
|
543
|
+
onChange={(v) => setItem(i, { ...item, image: v })}
|
|
544
|
+
/>
|
|
545
|
+
<LocalizedField
|
|
546
|
+
label="Alt text"
|
|
547
|
+
value={item.alt}
|
|
548
|
+
onChange={(v) => setItem(i, { ...item, alt: v })}
|
|
549
|
+
/>
|
|
550
|
+
<TextField
|
|
551
|
+
label="Post URL (optional)"
|
|
552
|
+
value={item.href}
|
|
553
|
+
placeholder="https://instagram.com/p/..."
|
|
554
|
+
onChange={(v) => setItem(i, { ...item, href: v })}
|
|
555
|
+
/>
|
|
556
|
+
</div>
|
|
557
|
+
))}
|
|
558
|
+
</div>
|
|
559
|
+
<button
|
|
560
|
+
onClick={add}
|
|
561
|
+
className="mt-3 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
|
|
562
|
+
>
|
|
563
|
+
<Plus className="size-3.5" /> Add image
|
|
564
|
+
</button>
|
|
565
|
+
</InspectorSection>
|
|
566
|
+
</>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/* ---------------- Prose (text section) ---------------- */
|
|
571
|
+
function ProseInspector({ block, patch }: BlockPatchProps) {
|
|
572
|
+
const p = (block.props ?? {}) as ProseProps;
|
|
573
|
+
const items = p.items ?? [];
|
|
574
|
+
const update = (next: ProseProps) => patch(next as unknown as BlockProps);
|
|
575
|
+
const setItem = (i: number, item: ProseItem) => {
|
|
576
|
+
const next = items.slice();
|
|
577
|
+
next[i] = item;
|
|
578
|
+
update({ ...p, items: next });
|
|
579
|
+
};
|
|
580
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
581
|
+
const j = i + dir;
|
|
582
|
+
if (j < 0 || j >= items.length) return;
|
|
583
|
+
const next = items.slice();
|
|
584
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
585
|
+
update({ ...p, items: next });
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<>
|
|
590
|
+
<InspectorSection title="Heading">
|
|
591
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
592
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
593
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
594
|
+
</InspectorSection>
|
|
595
|
+
<InspectorSection title="Layout">
|
|
596
|
+
<label className="block space-y-1.5">
|
|
597
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns</span>
|
|
598
|
+
<select
|
|
599
|
+
value={p.layout ?? "single"}
|
|
600
|
+
onChange={(e) => update({ ...p, layout: e.target.value as ProseProps["layout"] })}
|
|
601
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
602
|
+
>
|
|
603
|
+
<option value="single">Single column</option>
|
|
604
|
+
<option value="two-col">Two columns</option>
|
|
605
|
+
</select>
|
|
606
|
+
</label>
|
|
607
|
+
<label className="block space-y-1.5">
|
|
608
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
609
|
+
<select
|
|
610
|
+
value={p.tone ?? "white"}
|
|
611
|
+
onChange={(e) => update({ ...p, tone: e.target.value as ProseProps["tone"] })}
|
|
612
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
613
|
+
>
|
|
614
|
+
<option value="white">White</option>
|
|
615
|
+
<option value="paper">Paper</option>
|
|
616
|
+
</select>
|
|
617
|
+
</label>
|
|
618
|
+
</InspectorSection>
|
|
619
|
+
<InspectorSection title={`Passages (${items.length})`}>
|
|
620
|
+
<div className="space-y-4">
|
|
621
|
+
{items.map((item, i) => (
|
|
622
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
|
|
623
|
+
<div className="flex items-center justify-between">
|
|
624
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
625
|
+
<div className="flex items-center gap-1">
|
|
626
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
627
|
+
<ArrowUp className="size-3.5" />
|
|
628
|
+
</button>
|
|
629
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
630
|
+
<ArrowDown className="size-3.5" />
|
|
631
|
+
</button>
|
|
632
|
+
<button
|
|
633
|
+
onClick={() => {
|
|
634
|
+
const next = items.slice();
|
|
635
|
+
next.splice(i, 1);
|
|
636
|
+
update({ ...p, items: next });
|
|
637
|
+
}}
|
|
638
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
639
|
+
title="Delete"
|
|
640
|
+
>
|
|
641
|
+
<Trash2 className="size-3.5" />
|
|
642
|
+
</button>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
<LocalizedField label="Heading" value={item.heading} onChange={(v) => setItem(i, { ...item, heading: v })} />
|
|
646
|
+
<LocalizedField label="Body" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
|
|
647
|
+
</div>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
<button
|
|
651
|
+
onClick={() => update({ ...p, items: [...items, { id: uid("p"), heading: {}, body: {} }] })}
|
|
652
|
+
className="mt-1 inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
653
|
+
>
|
|
654
|
+
<Plus className="size-4" /> Add passage
|
|
655
|
+
</button>
|
|
656
|
+
</InspectorSection>
|
|
657
|
+
</>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
/* ---------------- Rich text (WYSIWYG) ---------------- */
|
|
663
|
+
function RichTextInspector({ block, patch }: BlockPatchProps) {
|
|
664
|
+
const p = (block.props ?? {}) as RichTextProps;
|
|
665
|
+
const update = (next: RichTextProps) => patch(next as unknown as BlockProps);
|
|
666
|
+
const [loc, setLoc] = useState<Locale>(SUPPORTED_LOCALES[0]);
|
|
667
|
+
const html = ((p.html ?? {}) as Record<string, string>)[loc] ?? "";
|
|
668
|
+
|
|
669
|
+
const select = <T extends string>(
|
|
670
|
+
label: string,
|
|
671
|
+
value: T,
|
|
672
|
+
options: { value: T; label: string }[],
|
|
673
|
+
onChange: (v: T) => void,
|
|
674
|
+
) => (
|
|
675
|
+
<label className="block space-y-1.5">
|
|
676
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
677
|
+
<select
|
|
678
|
+
value={value}
|
|
679
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
680
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
681
|
+
>
|
|
682
|
+
{options.map((o) => (
|
|
683
|
+
<option key={o.value} value={o.value}>
|
|
684
|
+
{o.label}
|
|
685
|
+
</option>
|
|
686
|
+
))}
|
|
687
|
+
</select>
|
|
688
|
+
</label>
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<>
|
|
693
|
+
<InspectorSection title="Heading (optional)">
|
|
694
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
695
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
696
|
+
</InspectorSection>
|
|
697
|
+
|
|
698
|
+
<InspectorSection title="Text">
|
|
699
|
+
<div className="flex gap-1">
|
|
700
|
+
{SUPPORTED_LOCALES.map((l) => (
|
|
701
|
+
<button
|
|
702
|
+
key={l}
|
|
703
|
+
type="button"
|
|
704
|
+
onClick={() => setLoc(l)}
|
|
705
|
+
className={[
|
|
706
|
+
"border px-2.5 py-1 text-[0.65rem] uppercase tracking-widest",
|
|
707
|
+
l === loc ? "border-sea bg-sea/10 text-ink" : "border-ink/15 text-ink/60 hover:text-ink",
|
|
708
|
+
].join(" ")}
|
|
709
|
+
>
|
|
710
|
+
{l}
|
|
711
|
+
</button>
|
|
712
|
+
))}
|
|
713
|
+
</div>
|
|
714
|
+
<RichTextEditor
|
|
715
|
+
key={loc}
|
|
716
|
+
value={html}
|
|
717
|
+
onChange={(next) =>
|
|
718
|
+
update({
|
|
719
|
+
...p,
|
|
720
|
+
html: { ...((p.html ?? {}) as Record<string, string>), [loc]: next } as RichTextProps["html"],
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
/>
|
|
724
|
+
</InspectorSection>
|
|
725
|
+
|
|
726
|
+
<InspectorSection title="Layout">
|
|
727
|
+
{select("Background", p.tone ?? "white", [
|
|
728
|
+
{ value: "white", label: "White" },
|
|
729
|
+
{ value: "paper", label: "Paper" },
|
|
730
|
+
{ value: "ice", label: "Ice" },
|
|
731
|
+
{ value: "ink", label: "Dark" },
|
|
732
|
+
] as { value: NonNullable<RichTextProps["tone"]>; label: string }[], (v) => update({ ...p, tone: v }))}
|
|
733
|
+
{select("Width", p.width ?? "normal", [
|
|
734
|
+
{ value: "narrow", label: "Narrow" },
|
|
735
|
+
{ value: "normal", label: "Normal" },
|
|
736
|
+
{ value: "wide", label: "Wide" },
|
|
737
|
+
{ value: "full", label: "Full width" },
|
|
738
|
+
] as { value: NonNullable<RichTextProps["width"]>; label: string }[], (v) => update({ ...p, width: v }))}
|
|
739
|
+
{select("Alignment", p.align ?? "left", [
|
|
740
|
+
{ value: "left", label: "Left" },
|
|
741
|
+
{ value: "center", label: "Centered" },
|
|
742
|
+
] as { value: NonNullable<RichTextProps["align"]>; label: string }[], (v) => update({ ...p, align: v }))}
|
|
743
|
+
{select("Spacing", p.padding ?? "normal", [
|
|
744
|
+
{ value: "none", label: "None" },
|
|
745
|
+
{ value: "compact", label: "Compact" },
|
|
746
|
+
{ value: "normal", label: "Normal" },
|
|
747
|
+
{ value: "spacious", label: "Spacious" },
|
|
748
|
+
] as { value: NonNullable<RichTextProps["padding"]>; label: string }[], (v) => update({ ...p, padding: v }))}
|
|
749
|
+
{select("Columns", String(p.columns ?? 1), [
|
|
750
|
+
{ value: "1", label: "One column" },
|
|
751
|
+
{ value: "2", label: "Two columns (desktop)" },
|
|
752
|
+
], (v) => update({ ...p, columns: Number(v) as 1 | 2 }))}
|
|
753
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
754
|
+
<input
|
|
755
|
+
type="checkbox"
|
|
756
|
+
checked={Boolean(p.dropCap)}
|
|
757
|
+
onChange={(e) => update({ ...p, dropCap: e.target.checked })}
|
|
758
|
+
className="size-4 accent-sea"
|
|
759
|
+
/>
|
|
760
|
+
Drop cap on the first paragraph
|
|
761
|
+
</label>
|
|
762
|
+
<TextField
|
|
763
|
+
label="Anchor id"
|
|
764
|
+
value={p.anchor}
|
|
765
|
+
onChange={(v) => update({ ...p, anchor: v })}
|
|
766
|
+
placeholder="e.g. villkor"
|
|
767
|
+
/>
|
|
768
|
+
</InspectorSection>
|
|
769
|
+
</>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* ---------------- Hero Section Fullscreen ---------------- */
|
|
774
|
+
function HeroFullInspector({ block, patch }: BlockPatchProps) {
|
|
775
|
+
const p = (block.props ?? {}) as HeroFullProps;
|
|
776
|
+
const update = (next: HeroFullProps) => patch(next as unknown as BlockProps);
|
|
777
|
+
const sel = <K extends keyof HeroFullProps>(
|
|
778
|
+
label: string,
|
|
779
|
+
key: K,
|
|
780
|
+
current: string,
|
|
781
|
+
options: { value: string; label: string }[],
|
|
782
|
+
) => (
|
|
783
|
+
<label className="block space-y-1.5">
|
|
784
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
785
|
+
<select
|
|
786
|
+
value={current}
|
|
787
|
+
onChange={(e) => update({ ...p, [key]: e.target.value } as HeroFullProps)}
|
|
788
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
789
|
+
>
|
|
790
|
+
{options.map((o) => (
|
|
791
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
792
|
+
))}
|
|
793
|
+
</select>
|
|
794
|
+
</label>
|
|
795
|
+
);
|
|
796
|
+
const check = (label: string, key: keyof HeroFullProps, fallback = false) => (
|
|
797
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
798
|
+
<input
|
|
799
|
+
type="checkbox"
|
|
800
|
+
checked={p[key] === undefined ? fallback : Boolean(p[key])}
|
|
801
|
+
onChange={(e) => update({ ...p, [key]: e.target.checked } as HeroFullProps)}
|
|
802
|
+
className="size-4 accent-sea"
|
|
803
|
+
/>
|
|
804
|
+
{label}
|
|
805
|
+
</label>
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
const mediaType = p.mediaType ?? "image";
|
|
809
|
+
|
|
810
|
+
return (
|
|
811
|
+
<>
|
|
812
|
+
<InspectorSection title="Text">
|
|
813
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
814
|
+
<LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
815
|
+
<LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
|
|
816
|
+
</InspectorSection>
|
|
817
|
+
|
|
818
|
+
<InspectorSection title="Buttons">
|
|
819
|
+
<LocalizedField
|
|
820
|
+
label="Primary label"
|
|
821
|
+
value={p.primaryCta?.label}
|
|
822
|
+
onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, label: v } })}
|
|
823
|
+
/>
|
|
824
|
+
<TextField
|
|
825
|
+
label="Primary link"
|
|
826
|
+
value={p.primaryCta?.href}
|
|
827
|
+
onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, href: v } })}
|
|
828
|
+
placeholder="/boka"
|
|
829
|
+
/>
|
|
830
|
+
<LocalizedField
|
|
831
|
+
label="Secondary label"
|
|
832
|
+
value={p.secondaryCta?.label}
|
|
833
|
+
onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, label: v } })}
|
|
834
|
+
/>
|
|
835
|
+
<TextField
|
|
836
|
+
label="Secondary link"
|
|
837
|
+
value={p.secondaryCta?.href}
|
|
838
|
+
onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })}
|
|
839
|
+
/>
|
|
840
|
+
</InspectorSection>
|
|
841
|
+
|
|
842
|
+
<InspectorSection title="Background media">
|
|
843
|
+
{sel("Media type", "mediaType", mediaType, [
|
|
844
|
+
{ value: "image", label: "Image" },
|
|
845
|
+
{ value: "video", label: "Video" },
|
|
846
|
+
])}
|
|
847
|
+
{mediaType === "image" ? (
|
|
848
|
+
<>
|
|
849
|
+
<MediaField
|
|
850
|
+
label="Image (desktop)"
|
|
851
|
+
accept="image"
|
|
852
|
+
value={p.image}
|
|
853
|
+
onChange={(v) => update({ ...p, image: v })}
|
|
854
|
+
/>
|
|
855
|
+
<MediaField
|
|
856
|
+
label="Image (mobile, optional)"
|
|
857
|
+
accept="image"
|
|
858
|
+
value={p.imageMobile}
|
|
859
|
+
onChange={(v) => update({ ...p, imageMobile: v })}
|
|
860
|
+
/>
|
|
861
|
+
<LocalizedField label="Image alt text" value={p.imageAlt} onChange={(v) => update({ ...p, imageAlt: v })} />
|
|
862
|
+
</>
|
|
863
|
+
) : (
|
|
864
|
+
<>
|
|
865
|
+
<MediaField
|
|
866
|
+
label="Video (desktop)"
|
|
867
|
+
accept="video"
|
|
868
|
+
value={p.videoSrc}
|
|
869
|
+
onChange={(v) => update({ ...p, videoSrc: v })}
|
|
870
|
+
/>
|
|
871
|
+
<MediaField
|
|
872
|
+
label="Poster (desktop)"
|
|
873
|
+
accept="image"
|
|
874
|
+
value={p.posterSrc}
|
|
875
|
+
onChange={(v) => update({ ...p, posterSrc: v })}
|
|
876
|
+
/>
|
|
877
|
+
<MediaField
|
|
878
|
+
label="Video WebM (desktop, optional)"
|
|
879
|
+
accept="video"
|
|
880
|
+
value={p.videoSrcWebm}
|
|
881
|
+
onChange={(v) => update({ ...p, videoSrcWebm: v })}
|
|
882
|
+
/>
|
|
883
|
+
|
|
884
|
+
<MediaField
|
|
885
|
+
label="Video (mobile, optional)"
|
|
886
|
+
accept="video"
|
|
887
|
+
value={p.videoSrcMobile}
|
|
888
|
+
onChange={(v) => update({ ...p, videoSrcMobile: v })}
|
|
889
|
+
/>
|
|
890
|
+
<MediaField
|
|
891
|
+
label="Video WebM (mobile, optional)"
|
|
892
|
+
accept="video"
|
|
893
|
+
value={p.videoSrcWebmMobile}
|
|
894
|
+
onChange={(v) => update({ ...p, videoSrcWebmMobile: v })}
|
|
895
|
+
/>
|
|
896
|
+
|
|
897
|
+
<MediaField
|
|
898
|
+
label="Poster (mobile, optional)"
|
|
899
|
+
accept="image"
|
|
900
|
+
value={p.posterSrcMobile}
|
|
901
|
+
onChange={(v) => update({ ...p, posterSrcMobile: v })}
|
|
902
|
+
/>
|
|
903
|
+
{check("Autoplay", "videoAutoplay", true)}
|
|
904
|
+
{check("Loop", "videoLoop", true)}
|
|
905
|
+
{check("Muted", "videoMuted", true)}
|
|
906
|
+
</>
|
|
907
|
+
)}
|
|
908
|
+
</InspectorSection>
|
|
909
|
+
|
|
910
|
+
<InspectorSection title="Layout">
|
|
911
|
+
{sel("Height", "height", p.height ?? "full", [
|
|
912
|
+
{ value: "full", label: "Fullscreen (100%)" },
|
|
913
|
+
{ value: "tall", label: "Tall (85%)" },
|
|
914
|
+
{ value: "medium", label: "Medium (65%)" },
|
|
915
|
+
{ value: "compact", label: "Compact (50%)" },
|
|
916
|
+
])}
|
|
917
|
+
{sel("Overlay", "overlay", p.overlay ?? "medium", [
|
|
918
|
+
{ value: "none", label: "None" },
|
|
919
|
+
{ value: "light", label: "Light" },
|
|
920
|
+
{ value: "medium", label: "Medium" },
|
|
921
|
+
{ value: "strong", label: "Strong" },
|
|
922
|
+
{ value: "bottom", label: "Gradient from bottom" },
|
|
923
|
+
{ value: "top", label: "Gradient from top" },
|
|
924
|
+
{ value: "side", label: "Gradient from left" },
|
|
925
|
+
])}
|
|
926
|
+
{sel("Horizontal alignment", "textAlign", p.textAlign ?? "left", [
|
|
927
|
+
{ value: "left", label: "Left" },
|
|
928
|
+
{ value: "center", label: "Centered" },
|
|
929
|
+
{ value: "right", label: "Right" },
|
|
930
|
+
])}
|
|
931
|
+
{sel("Vertical alignment", "verticalAlign", p.verticalAlign ?? "center", [
|
|
932
|
+
{ value: "top", label: "Top" },
|
|
933
|
+
{ value: "center", label: "Middle" },
|
|
934
|
+
{ value: "bottom", label: "Bottom" },
|
|
935
|
+
])}
|
|
936
|
+
{sel("Content width", "contentWidth", p.contentWidth ?? "normal", [
|
|
937
|
+
{ value: "narrow", label: "Narrow" },
|
|
938
|
+
{ value: "normal", label: "Normal" },
|
|
939
|
+
{ value: "wide", label: "Wide" },
|
|
940
|
+
{ value: "full", label: "Full width" },
|
|
941
|
+
])}
|
|
942
|
+
{sel("Text colour", "textTone", p.textTone ?? "light", [
|
|
943
|
+
{ value: "light", label: "Light text (dark media)" },
|
|
944
|
+
{ value: "dark", label: "Dark text (light media)" },
|
|
945
|
+
])}
|
|
946
|
+
{sel("Title size", "titleSize", p.titleSize ?? "lg", [
|
|
947
|
+
{ value: "sm", label: "Small" },
|
|
948
|
+
{ value: "md", label: "Medium" },
|
|
949
|
+
{ value: "lg", label: "Large" },
|
|
950
|
+
{ value: "xl", label: "Extra large" },
|
|
951
|
+
])}
|
|
952
|
+
{sel("Title font", "titleFont", p.titleFont ?? "serif", [
|
|
953
|
+
{ value: "serif", label: "Serif" },
|
|
954
|
+
{ value: "sans", label: "Sans" },
|
|
955
|
+
])}
|
|
956
|
+
{check("Uppercase title", "titleUppercase")}
|
|
957
|
+
{check("Show scroll cue", "showScrollCue", true)}
|
|
958
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
959
|
+
</InspectorSection>
|
|
960
|
+
</>
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/* ---------------- Image marquee ---------------- */
|
|
965
|
+
function ImageMarqueeInspector({ block, patch }: BlockPatchProps) {
|
|
966
|
+
const p = (block.props ?? {}) as ImageMarqueeProps;
|
|
967
|
+
const items = p.items ?? [];
|
|
968
|
+
const texts = p.items2 ?? [];
|
|
969
|
+
const [addColumn, setAddColumn] = useState("");
|
|
970
|
+
const columnOptions = Array.from({ length: Math.min(3, Math.max(1, p.columns ?? 2)) }, (_, i) => ({
|
|
971
|
+
value: String(i + 1),
|
|
972
|
+
label: `Column ${i + 1}`,
|
|
973
|
+
}));
|
|
974
|
+
const update = (next: ImageMarqueeProps) => patch(next as unknown as BlockProps);
|
|
975
|
+
const setItem = (i: number, item: ImageMarqueeItem) => {
|
|
976
|
+
const next = items.slice();
|
|
977
|
+
next[i] = item;
|
|
978
|
+
update({ ...p, items: next });
|
|
979
|
+
};
|
|
980
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
981
|
+
const j = i + dir;
|
|
982
|
+
if (j < 0 || j >= items.length) return;
|
|
983
|
+
const next = items.slice();
|
|
984
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
985
|
+
update({ ...p, items: next });
|
|
986
|
+
};
|
|
987
|
+
const sel = (
|
|
988
|
+
label: string,
|
|
989
|
+
value: string,
|
|
990
|
+
options: { value: string; label: string }[],
|
|
991
|
+
onChange: (v: string) => void,
|
|
992
|
+
) => (
|
|
993
|
+
<label className="block space-y-1.5">
|
|
994
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
995
|
+
<select
|
|
996
|
+
value={value}
|
|
997
|
+
onChange={(e) => onChange(e.target.value)}
|
|
998
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
999
|
+
>
|
|
1000
|
+
{options.map((o) => (
|
|
1001
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
1002
|
+
))}
|
|
1003
|
+
</select>
|
|
1004
|
+
</label>
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
return (
|
|
1008
|
+
<>
|
|
1009
|
+
<InspectorSection title="Text">
|
|
1010
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1011
|
+
<LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1012
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
1013
|
+
<div className="space-y-3">
|
|
1014
|
+
{texts.map((item, i) => (
|
|
1015
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-2">
|
|
1016
|
+
<div className="flex items-center justify-between">
|
|
1017
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">Passage #{i + 1}</span>
|
|
1018
|
+
<button
|
|
1019
|
+
onClick={() => {
|
|
1020
|
+
const next = texts.slice();
|
|
1021
|
+
next.splice(i, 1);
|
|
1022
|
+
update({ ...p, items2: next });
|
|
1023
|
+
}}
|
|
1024
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1025
|
+
title="Delete"
|
|
1026
|
+
>
|
|
1027
|
+
<Trash2 className="size-3.5" />
|
|
1028
|
+
</button>
|
|
1029
|
+
</div>
|
|
1030
|
+
<LocalizedField
|
|
1031
|
+
label="Heading"
|
|
1032
|
+
value={item.heading}
|
|
1033
|
+
onChange={(v) => {
|
|
1034
|
+
const next = texts.slice();
|
|
1035
|
+
next[i] = { ...item, heading: v };
|
|
1036
|
+
update({ ...p, items2: next });
|
|
1037
|
+
}}
|
|
1038
|
+
/>
|
|
1039
|
+
<LocalizedField
|
|
1040
|
+
label="Body"
|
|
1041
|
+
multiline
|
|
1042
|
+
value={item.body}
|
|
1043
|
+
onChange={(v) => {
|
|
1044
|
+
const next = texts.slice();
|
|
1045
|
+
next[i] = { ...item, body: v };
|
|
1046
|
+
update({ ...p, items2: next });
|
|
1047
|
+
}}
|
|
1048
|
+
/>
|
|
1049
|
+
</div>
|
|
1050
|
+
))}
|
|
1051
|
+
<button
|
|
1052
|
+
onClick={() => update({ ...p, items2: [...texts, { id: uid("mtxt") }] })}
|
|
1053
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1054
|
+
>
|
|
1055
|
+
<Plus className="size-3.5" /> Add passage
|
|
1056
|
+
</button>
|
|
1057
|
+
</div>
|
|
1058
|
+
</InspectorSection>
|
|
1059
|
+
<InspectorSection title="Layout">
|
|
1060
|
+
{sel("Text position", p.textPosition ?? "left", [
|
|
1061
|
+
{ value: "left", label: "Text left, images right" },
|
|
1062
|
+
{ value: "right", label: "Text right, images left" },
|
|
1063
|
+
{ value: "none", label: "Images only (full width)" },
|
|
1064
|
+
], (v) => update({ ...p, textPosition: v as ImageMarqueeProps["textPosition"] }))}
|
|
1065
|
+
{sel("Columns (desktop)", String(p.columns ?? 2), [
|
|
1066
|
+
{ value: "1", label: "1 column" },
|
|
1067
|
+
{ value: "2", label: "2 columns" },
|
|
1068
|
+
{ value: "3", label: "3 columns" },
|
|
1069
|
+
], (v) => update({ ...p, columns: Number(v) as ImageMarqueeProps["columns"] }))}
|
|
1070
|
+
{sel("First column direction", p.firstDirection ?? "up", [
|
|
1071
|
+
{ value: "up", label: "Up (next column down)" },
|
|
1072
|
+
{ value: "down", label: "Down (next column up)" },
|
|
1073
|
+
], (v) => update({ ...p, firstDirection: v as ImageMarqueeProps["firstDirection"] }))}
|
|
1074
|
+
{sel("Speed", p.speed ?? "normal", [
|
|
1075
|
+
{ value: "ultra-slow", label: "Ultra slow" },
|
|
1076
|
+
{ value: "very-slow", label: "Very slow" },
|
|
1077
|
+
{ value: "slow", label: "Slow" },
|
|
1078
|
+
{ value: "normal", label: "Normal" },
|
|
1079
|
+
{ value: "fast", label: "Fast" },
|
|
1080
|
+
], (v) => update({ ...p, speed: v as ImageMarqueeProps["speed"] }))}
|
|
1081
|
+
{sel("Image shape", p.aspect ?? "portrait", [
|
|
1082
|
+
{ value: "portrait", label: "Portrait" },
|
|
1083
|
+
{ value: "square", label: "Square" },
|
|
1084
|
+
{ value: "landscape", label: "Landscape" },
|
|
1085
|
+
{ value: "mixed", label: "Mixed" },
|
|
1086
|
+
], (v) => update({ ...p, aspect: v as ImageMarqueeProps["aspect"] }))}
|
|
1087
|
+
{sel("Gap", p.gap ?? "md", [
|
|
1088
|
+
{ value: "sm", label: "Small" },
|
|
1089
|
+
{ value: "md", label: "Medium" },
|
|
1090
|
+
{ value: "lg", label: "Large" },
|
|
1091
|
+
], (v) => update({ ...p, gap: v as ImageMarqueeProps["gap"] }))}
|
|
1092
|
+
{sel("Background", p.tone ?? "white", [
|
|
1093
|
+
{ value: "white", label: "White" },
|
|
1094
|
+
{ value: "paper", label: "Paper" },
|
|
1095
|
+
{ value: "ice", label: "Ice" },
|
|
1096
|
+
], (v) => update({ ...p, tone: v as ImageMarqueeProps["tone"] }))}
|
|
1097
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
1098
|
+
<input
|
|
1099
|
+
type="checkbox"
|
|
1100
|
+
checked={p.rounded ?? false}
|
|
1101
|
+
onChange={(e) => update({ ...p, rounded: e.target.checked })}
|
|
1102
|
+
/>
|
|
1103
|
+
Rounded corners
|
|
1104
|
+
</label>
|
|
1105
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1106
|
+
</InspectorSection>
|
|
1107
|
+
<InspectorSection title={`Images (${items.length})`}>
|
|
1108
|
+
<div className="space-y-4">
|
|
1109
|
+
{items.map((item, i) => (
|
|
1110
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
|
|
1111
|
+
<div className="flex items-center justify-between">
|
|
1112
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
1113
|
+
<div className="flex items-center gap-1">
|
|
1114
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
1115
|
+
<ArrowUp className="size-3.5" />
|
|
1116
|
+
</button>
|
|
1117
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
1118
|
+
<ArrowDown className="size-3.5" />
|
|
1119
|
+
</button>
|
|
1120
|
+
<button
|
|
1121
|
+
onClick={() => {
|
|
1122
|
+
const next = items.slice();
|
|
1123
|
+
next.splice(i, 1);
|
|
1124
|
+
update({ ...p, items: next });
|
|
1125
|
+
}}
|
|
1126
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1127
|
+
title="Delete"
|
|
1128
|
+
>
|
|
1129
|
+
<Trash2 className="size-3.5" />
|
|
1130
|
+
</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
</div>
|
|
1133
|
+
<MediaField
|
|
1134
|
+
label="Image"
|
|
1135
|
+
accept="image"
|
|
1136
|
+
value={item.image}
|
|
1137
|
+
onChange={(url) => setItem(i, { ...item, image: url })}
|
|
1138
|
+
/>
|
|
1139
|
+
{sel(
|
|
1140
|
+
"Column",
|
|
1141
|
+
String(item.column ?? ""),
|
|
1142
|
+
[
|
|
1143
|
+
{ value: "", label: "All columns (spread)" },
|
|
1144
|
+
...columnOptions,
|
|
1145
|
+
],
|
|
1146
|
+
(v) => setItem(i, { ...item, column: v ? Number(v) : undefined }),
|
|
1147
|
+
)}
|
|
1148
|
+
<LocalizedField label="Alt text" value={item.alt} onChange={(v) => setItem(i, { ...item, alt: v })} />
|
|
1149
|
+
</div>
|
|
1150
|
+
))}
|
|
1151
|
+
</div>
|
|
1152
|
+
{sel(
|
|
1153
|
+
"Add new images to",
|
|
1154
|
+
String(addColumn),
|
|
1155
|
+
[{ value: "", label: "All columns (spread)" }, ...columnOptions],
|
|
1156
|
+
(v) => setAddColumn(v),
|
|
1157
|
+
)}
|
|
1158
|
+
<MediaMultiField
|
|
1159
|
+
label="Add several images at once"
|
|
1160
|
+
values={[]}
|
|
1161
|
+
onChange={(urls) => {
|
|
1162
|
+
update({
|
|
1163
|
+
...p,
|
|
1164
|
+
items: [
|
|
1165
|
+
...items,
|
|
1166
|
+
...urls.map((url) => ({
|
|
1167
|
+
id: uid("img"),
|
|
1168
|
+
image: url,
|
|
1169
|
+
...(addColumn ? { column: Number(addColumn) } : {}),
|
|
1170
|
+
})),
|
|
1171
|
+
],
|
|
1172
|
+
});
|
|
1173
|
+
}}
|
|
1174
|
+
/>
|
|
1175
|
+
<button
|
|
1176
|
+
onClick={() =>
|
|
1177
|
+
update({
|
|
1178
|
+
...p,
|
|
1179
|
+
items: [
|
|
1180
|
+
...items,
|
|
1181
|
+
{ id: uid("img"), ...(addColumn ? { column: Number(addColumn) } : {}) },
|
|
1182
|
+
],
|
|
1183
|
+
})
|
|
1184
|
+
}
|
|
1185
|
+
className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1186
|
+
>
|
|
1187
|
+
<Plus className="size-3.5" /> Add single image
|
|
1188
|
+
</button>
|
|
1189
|
+
</InspectorSection>
|
|
1190
|
+
</>
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
/* ---------------- Link tiles ---------------- */
|
|
1196
|
+
function LinkTilesInspector({ block, patch }: BlockPatchProps) {
|
|
1197
|
+
const p = (block.props ?? {}) as LinkTilesProps;
|
|
1198
|
+
const items = p.items ?? [];
|
|
1199
|
+
const update = (next: LinkTilesProps) => patch(next as unknown as BlockProps);
|
|
1200
|
+
const setItem = (i: number, item: LinkTilesItem) => {
|
|
1201
|
+
const next = items.slice();
|
|
1202
|
+
next[i] = item;
|
|
1203
|
+
update({ ...p, items: next });
|
|
1204
|
+
};
|
|
1205
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
1206
|
+
const j = i + dir;
|
|
1207
|
+
if (j < 0 || j >= items.length) return;
|
|
1208
|
+
const next = items.slice();
|
|
1209
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1210
|
+
update({ ...p, items: next });
|
|
1211
|
+
};
|
|
1212
|
+
const sel = (
|
|
1213
|
+
label: string,
|
|
1214
|
+
value: string,
|
|
1215
|
+
options: { value: string; label: string }[],
|
|
1216
|
+
onChange: (v: string) => void,
|
|
1217
|
+
) => (
|
|
1218
|
+
<label className="block space-y-1.5">
|
|
1219
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
1220
|
+
<select
|
|
1221
|
+
value={value}
|
|
1222
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1223
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1224
|
+
>
|
|
1225
|
+
{options.map((o) => (
|
|
1226
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
1227
|
+
))}
|
|
1228
|
+
</select>
|
|
1229
|
+
</label>
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
return (
|
|
1233
|
+
<>
|
|
1234
|
+
<InspectorSection title="Heading (optional)">
|
|
1235
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1236
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1237
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
1238
|
+
</InspectorSection>
|
|
1239
|
+
<InspectorSection title="Layout">
|
|
1240
|
+
{sel("Columns (desktop)", String(p.columns ?? 3), [
|
|
1241
|
+
{ value: "1", label: "1 column" },
|
|
1242
|
+
{ value: "2", label: "2 columns" },
|
|
1243
|
+
{ value: "3", label: "3 columns" },
|
|
1244
|
+
{ value: "4", label: "4 columns" },
|
|
1245
|
+
], (v) => update({ ...p, columns: Number(v) as LinkTilesProps["columns"] }))}
|
|
1246
|
+
{sel("Tile shape", p.aspect ?? "portrait", [
|
|
1247
|
+
{ value: "portrait", label: "Portrait" },
|
|
1248
|
+
{ value: "tall", label: "Tall" },
|
|
1249
|
+
{ value: "square", label: "Square" },
|
|
1250
|
+
{ value: "landscape", label: "Landscape" },
|
|
1251
|
+
], (v) => update({ ...p, aspect: v as LinkTilesProps["aspect"] }))}
|
|
1252
|
+
{sel("Gap", p.gap ?? "md", [
|
|
1253
|
+
{ value: "sm", label: "Small" },
|
|
1254
|
+
{ value: "md", label: "Medium" },
|
|
1255
|
+
{ value: "lg", label: "Large" },
|
|
1256
|
+
], (v) => update({ ...p, gap: v as LinkTilesProps["gap"] }))}
|
|
1257
|
+
{sel("Image overlay", p.overlay ?? "medium", [
|
|
1258
|
+
{ value: "none", label: "None" },
|
|
1259
|
+
{ value: "light", label: "Light" },
|
|
1260
|
+
{ value: "medium", label: "Medium" },
|
|
1261
|
+
{ value: "strong", label: "Strong" },
|
|
1262
|
+
{ value: "brand", label: "Brand tint (pink)" },
|
|
1263
|
+
], (v) => update({ ...p, overlay: v as LinkTilesProps["overlay"] }))}
|
|
1264
|
+
{sel("Label position", p.labelAlign ?? "left", [
|
|
1265
|
+
{ value: "left", label: "Bottom left" },
|
|
1266
|
+
{ value: "center", label: "Bottom centre" },
|
|
1267
|
+
], (v) => update({ ...p, labelAlign: v as LinkTilesProps["labelAlign"] }))}
|
|
1268
|
+
{sel("Background", p.tone ?? "white", [
|
|
1269
|
+
{ value: "white", label: "White" },
|
|
1270
|
+
{ value: "paper", label: "Paper" },
|
|
1271
|
+
{ value: "ice", label: "Ice" },
|
|
1272
|
+
{ value: "ink", label: "Ink (dark)" },
|
|
1273
|
+
], (v) => update({ ...p, tone: v as LinkTilesProps["tone"] }))}
|
|
1274
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
1275
|
+
<input
|
|
1276
|
+
type="checkbox"
|
|
1277
|
+
checked={p.rounded ?? false}
|
|
1278
|
+
onChange={(e) => update({ ...p, rounded: e.target.checked })}
|
|
1279
|
+
/>
|
|
1280
|
+
Rounded corners
|
|
1281
|
+
</label>
|
|
1282
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1283
|
+
</InspectorSection>
|
|
1284
|
+
<InspectorSection title={`Tiles (${items.length})`}>
|
|
1285
|
+
<div className="space-y-4">
|
|
1286
|
+
{items.map((item, i) => (
|
|
1287
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
|
|
1288
|
+
<div className="flex items-center justify-between">
|
|
1289
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
1290
|
+
<div className="flex items-center gap-1">
|
|
1291
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
1292
|
+
<ArrowUp className="size-3.5" />
|
|
1293
|
+
</button>
|
|
1294
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
1295
|
+
<ArrowDown className="size-3.5" />
|
|
1296
|
+
</button>
|
|
1297
|
+
<button
|
|
1298
|
+
onClick={() => {
|
|
1299
|
+
const next = items.slice();
|
|
1300
|
+
next.splice(i, 1);
|
|
1301
|
+
update({ ...p, items: next });
|
|
1302
|
+
}}
|
|
1303
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1304
|
+
title="Delete"
|
|
1305
|
+
>
|
|
1306
|
+
<Trash2 className="size-3.5" />
|
|
1307
|
+
</button>
|
|
1308
|
+
</div>
|
|
1309
|
+
</div>
|
|
1310
|
+
<MediaField
|
|
1311
|
+
label="Image"
|
|
1312
|
+
accept="image"
|
|
1313
|
+
value={item.image}
|
|
1314
|
+
onChange={(url) => setItem(i, { ...item, image: url })}
|
|
1315
|
+
/>
|
|
1316
|
+
<LocalizedField label="Label" value={item.label} onChange={(v) => setItem(i, { ...item, label: v })} />
|
|
1317
|
+
<LocalizedField label="Eyebrow" value={item.eyebrow} onChange={(v) => setItem(i, { ...item, eyebrow: v })} />
|
|
1318
|
+
<LocalizedField label="Text" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
|
|
1319
|
+
<LocalizedField label="Alt text" value={item.alt} onChange={(v) => setItem(i, { ...item, alt: v })} />
|
|
1320
|
+
<TextField
|
|
1321
|
+
label="Link"
|
|
1322
|
+
value={item.href}
|
|
1323
|
+
placeholder="/boka"
|
|
1324
|
+
onChange={(v) => setItem(i, { ...item, href: v })}
|
|
1325
|
+
/>
|
|
1326
|
+
</div>
|
|
1327
|
+
))}
|
|
1328
|
+
</div>
|
|
1329
|
+
<button
|
|
1330
|
+
onClick={() => update({ ...p, items: [...items, { id: uid("tile") }] })}
|
|
1331
|
+
className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1332
|
+
>
|
|
1333
|
+
<Plus className="size-3.5" /> Add tile
|
|
1334
|
+
</button>
|
|
1335
|
+
</InspectorSection>
|
|
1336
|
+
</>
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
/* ---------------- Cabin tabs ---------------- */
|
|
1342
|
+
function CabinTabsInspector({ block, patch }: BlockPatchProps) {
|
|
1343
|
+
const p = (block.props ?? {}) as CabinTabsProps;
|
|
1344
|
+
const items = p.items ?? [];
|
|
1345
|
+
const update = (next: CabinTabsProps) => patch(next as unknown as BlockProps);
|
|
1346
|
+
const setItem = (i: number, item: CabinTabItem) => {
|
|
1347
|
+
const next = items.slice();
|
|
1348
|
+
next[i] = item;
|
|
1349
|
+
update({ ...p, items: next });
|
|
1350
|
+
};
|
|
1351
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
1352
|
+
const j = i + dir;
|
|
1353
|
+
if (j < 0 || j >= items.length) return;
|
|
1354
|
+
const next = items.slice();
|
|
1355
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1356
|
+
update({ ...p, items: next });
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
return (
|
|
1360
|
+
<>
|
|
1361
|
+
<InspectorSection title="Heading">
|
|
1362
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1363
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1364
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
1365
|
+
<LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
|
|
1366
|
+
<label className="block space-y-1.5">
|
|
1367
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
1368
|
+
<select
|
|
1369
|
+
value={p.tone ?? "white"}
|
|
1370
|
+
onChange={(e) => update({ ...p, tone: e.target.value as CabinTabsProps["tone"] })}
|
|
1371
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1372
|
+
>
|
|
1373
|
+
<option value="white">White</option>
|
|
1374
|
+
<option value="paper">Paper</option>
|
|
1375
|
+
<option value="ice">Ice</option>
|
|
1376
|
+
</select>
|
|
1377
|
+
</label>
|
|
1378
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1379
|
+
</InspectorSection>
|
|
1380
|
+
<InspectorSection title={`Cabins (${items.length})`}>
|
|
1381
|
+
<div className="space-y-4">
|
|
1382
|
+
{items.map((item, i) => {
|
|
1383
|
+
const bullets = item.bullets ?? [];
|
|
1384
|
+
const images = item.images ?? [];
|
|
1385
|
+
return (
|
|
1386
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
|
|
1387
|
+
<div className="flex items-center justify-between">
|
|
1388
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
1389
|
+
<div className="flex items-center gap-1">
|
|
1390
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
1391
|
+
<ArrowUp className="size-3.5" />
|
|
1392
|
+
</button>
|
|
1393
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
1394
|
+
<ArrowDown className="size-3.5" />
|
|
1395
|
+
</button>
|
|
1396
|
+
<button
|
|
1397
|
+
onClick={() => {
|
|
1398
|
+
const next = items.slice();
|
|
1399
|
+
next.splice(i, 1);
|
|
1400
|
+
update({ ...p, items: next });
|
|
1401
|
+
}}
|
|
1402
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1403
|
+
title="Delete"
|
|
1404
|
+
>
|
|
1405
|
+
<Trash2 className="size-3.5" />
|
|
1406
|
+
</button>
|
|
1407
|
+
</div>
|
|
1408
|
+
</div>
|
|
1409
|
+
<LocalizedField label="Tab label" value={item.label} onChange={(v) => setItem(i, { ...item, label: v })} />
|
|
1410
|
+
<LocalizedField label="Heading" value={item.title} onChange={(v) => setItem(i, { ...item, title: v })} />
|
|
1411
|
+
<LocalizedField label="Description" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
|
|
1412
|
+
|
|
1413
|
+
<div className="space-y-2">
|
|
1414
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
1415
|
+
Bullets ({bullets.length})
|
|
1416
|
+
</span>
|
|
1417
|
+
{bullets.map((b, bi) => (
|
|
1418
|
+
<div key={b.id ?? bi} className="flex items-start gap-2">
|
|
1419
|
+
<div className="min-w-0 flex-1">
|
|
1420
|
+
<LocalizedField
|
|
1421
|
+
label={`Bullet ${bi + 1}`}
|
|
1422
|
+
value={b.text}
|
|
1423
|
+
onChange={(v) => {
|
|
1424
|
+
const next = bullets.slice();
|
|
1425
|
+
next[bi] = { ...b, text: v };
|
|
1426
|
+
setItem(i, { ...item, bullets: next });
|
|
1427
|
+
}}
|
|
1428
|
+
/>
|
|
1429
|
+
</div>
|
|
1430
|
+
<button
|
|
1431
|
+
onClick={() => setItem(i, { ...item, bullets: bullets.filter((_, x) => x !== bi) })}
|
|
1432
|
+
className="mt-6 p-1 text-red-500 hover:text-red-700"
|
|
1433
|
+
title="Delete bullet"
|
|
1434
|
+
>
|
|
1435
|
+
<Trash2 className="size-3.5" />
|
|
1436
|
+
</button>
|
|
1437
|
+
</div>
|
|
1438
|
+
))}
|
|
1439
|
+
<button
|
|
1440
|
+
onClick={() => setItem(i, { ...item, bullets: [...bullets, { id: uid("b") }] })}
|
|
1441
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1442
|
+
>
|
|
1443
|
+
<Plus className="size-3" /> Add bullet
|
|
1444
|
+
</button>
|
|
1445
|
+
</div>
|
|
1446
|
+
|
|
1447
|
+
<div className="space-y-2">
|
|
1448
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
1449
|
+
Images ({images.length})
|
|
1450
|
+
</span>
|
|
1451
|
+
{images.map((im, ii) => (
|
|
1452
|
+
<div key={im.id ?? ii} className="border border-ink/10 p-2 space-y-2">
|
|
1453
|
+
<div className="flex items-center justify-between">
|
|
1454
|
+
<span className="text-[0.65rem] uppercase tracking-widest text-ink/45">Image {ii + 1}</span>
|
|
1455
|
+
<button
|
|
1456
|
+
onClick={() => setItem(i, { ...item, images: images.filter((_, x) => x !== ii) })}
|
|
1457
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1458
|
+
title="Delete image"
|
|
1459
|
+
>
|
|
1460
|
+
<Trash2 className="size-3.5" />
|
|
1461
|
+
</button>
|
|
1462
|
+
</div>
|
|
1463
|
+
<MediaField
|
|
1464
|
+
label="Image"
|
|
1465
|
+
accept="image"
|
|
1466
|
+
value={im.src}
|
|
1467
|
+
onChange={(url) => {
|
|
1468
|
+
const next = images.slice();
|
|
1469
|
+
next[ii] = { ...im, src: url };
|
|
1470
|
+
setItem(i, { ...item, images: next });
|
|
1471
|
+
}}
|
|
1472
|
+
/>
|
|
1473
|
+
<LocalizedField
|
|
1474
|
+
label="Alt text"
|
|
1475
|
+
value={im.alt}
|
|
1476
|
+
onChange={(v) => {
|
|
1477
|
+
const next = images.slice();
|
|
1478
|
+
next[ii] = { ...im, alt: v };
|
|
1479
|
+
setItem(i, { ...item, images: next });
|
|
1480
|
+
}}
|
|
1481
|
+
/>
|
|
1482
|
+
</div>
|
|
1483
|
+
))}
|
|
1484
|
+
<button
|
|
1485
|
+
onClick={() => setItem(i, { ...item, images: [...images, { id: uid("img") }] })}
|
|
1486
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1487
|
+
>
|
|
1488
|
+
<Plus className="size-3" /> Add image
|
|
1489
|
+
</button>
|
|
1490
|
+
</div>
|
|
1491
|
+
</div>
|
|
1492
|
+
);
|
|
1493
|
+
})}
|
|
1494
|
+
</div>
|
|
1495
|
+
<button
|
|
1496
|
+
onClick={() => update({ ...p, items: [...items, { id: uid("cabin") }] })}
|
|
1497
|
+
className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1498
|
+
>
|
|
1499
|
+
<Plus className="size-3.5" /> Add cabin
|
|
1500
|
+
</button>
|
|
1501
|
+
</InspectorSection>
|
|
1502
|
+
</>
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/* ---------------- Price table ---------------- */
|
|
1507
|
+
function PriceTableInspector({ block, patch }: BlockPatchProps) {
|
|
1508
|
+
const p = (block.props ?? {}) as PriceTableProps;
|
|
1509
|
+
const groups = p.groups ?? [];
|
|
1510
|
+
const update = (next: PriceTableProps) => patch(next as unknown as BlockProps);
|
|
1511
|
+
const setGroup = (i: number, g: PriceGroup) => {
|
|
1512
|
+
const next = groups.slice();
|
|
1513
|
+
next[i] = g;
|
|
1514
|
+
update({ ...p, groups: next });
|
|
1515
|
+
};
|
|
1516
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
1517
|
+
const j = i + dir;
|
|
1518
|
+
if (j < 0 || j >= groups.length) return;
|
|
1519
|
+
const next = groups.slice();
|
|
1520
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1521
|
+
update({ ...p, groups: next });
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
return (
|
|
1525
|
+
<>
|
|
1526
|
+
<InspectorSection title="Heading">
|
|
1527
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1528
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1529
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
1530
|
+
<LocalizedField label="Unit label" value={p.unitLabel} onChange={(v) => update({ ...p, unitLabel: v })} />
|
|
1531
|
+
<LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
|
|
1532
|
+
<LocalizedField label="Button label" value={p.ctaLabel} onChange={(v) => update({ ...p, ctaLabel: v })} />
|
|
1533
|
+
<TextField label="Button link" value={p.ctaHref} placeholder="/boka" onChange={(v) => update({ ...p, ctaHref: v })} />
|
|
1534
|
+
</InspectorSection>
|
|
1535
|
+
<InspectorSection title="Layout">
|
|
1536
|
+
<label className="block space-y-1.5">
|
|
1537
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns (desktop)</span>
|
|
1538
|
+
<select
|
|
1539
|
+
value={String(p.columns ?? 3)}
|
|
1540
|
+
onChange={(e) => update({ ...p, columns: Number(e.target.value) as PriceTableProps["columns"] })}
|
|
1541
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1542
|
+
>
|
|
1543
|
+
<option value="2">2 columns</option>
|
|
1544
|
+
<option value="3">3 columns</option>
|
|
1545
|
+
</select>
|
|
1546
|
+
</label>
|
|
1547
|
+
<label className="block space-y-1.5">
|
|
1548
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
1549
|
+
<select
|
|
1550
|
+
value={p.tone ?? "paper"}
|
|
1551
|
+
onChange={(e) => update({ ...p, tone: e.target.value as PriceTableProps["tone"] })}
|
|
1552
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1553
|
+
>
|
|
1554
|
+
<option value="white">White</option>
|
|
1555
|
+
<option value="paper">Paper</option>
|
|
1556
|
+
<option value="ice">Ice</option>
|
|
1557
|
+
<option value="ink">Ink (dark)</option>
|
|
1558
|
+
</select>
|
|
1559
|
+
</label>
|
|
1560
|
+
<label className="block space-y-1.5">
|
|
1561
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Alignment</span>
|
|
1562
|
+
<select
|
|
1563
|
+
value={p.align ?? "center"}
|
|
1564
|
+
onChange={(e) => update({ ...p, align: e.target.value as PriceTableProps["align"] })}
|
|
1565
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1566
|
+
>
|
|
1567
|
+
<option value="center">Centered</option>
|
|
1568
|
+
<option value="left">Left aligned</option>
|
|
1569
|
+
</select>
|
|
1570
|
+
</label>
|
|
1571
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1572
|
+
|
|
1573
|
+
</InspectorSection>
|
|
1574
|
+
<InspectorSection title={`Price cards (${groups.length})`}>
|
|
1575
|
+
<div className="space-y-4">
|
|
1576
|
+
{groups.map((g, i) => {
|
|
1577
|
+
const rows = g.rows ?? [];
|
|
1578
|
+
return (
|
|
1579
|
+
<div key={g.id ?? i} className="border border-ink/10 p-3 space-y-3 min-w-0">
|
|
1580
|
+
<div className="flex items-center justify-between">
|
|
1581
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
1582
|
+
<div className="flex items-center gap-1">
|
|
1583
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
1584
|
+
<ArrowUp className="size-3.5" />
|
|
1585
|
+
</button>
|
|
1586
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
1587
|
+
<ArrowDown className="size-3.5" />
|
|
1588
|
+
</button>
|
|
1589
|
+
<button
|
|
1590
|
+
onClick={() => update({ ...p, groups: groups.filter((_, x) => x !== i) })}
|
|
1591
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1592
|
+
title="Delete"
|
|
1593
|
+
>
|
|
1594
|
+
<Trash2 className="size-3.5" />
|
|
1595
|
+
</button>
|
|
1596
|
+
</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
<LocalizedField label="Cabin" value={g.label} onChange={(v) => setGroup(i, { ...g, label: v })} />
|
|
1599
|
+
<LocalizedField label="Caption" multiline value={g.caption} onChange={(v) => setGroup(i, { ...g, caption: v })} />
|
|
1600
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
1601
|
+
<input
|
|
1602
|
+
type="checkbox"
|
|
1603
|
+
checked={g.highlight ?? false}
|
|
1604
|
+
onChange={(e) => setGroup(i, { ...g, highlight: e.target.checked })}
|
|
1605
|
+
/>
|
|
1606
|
+
Highlight card
|
|
1607
|
+
</label>
|
|
1608
|
+
<div className="space-y-2">
|
|
1609
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
1610
|
+
Rows ({rows.length})
|
|
1611
|
+
</span>
|
|
1612
|
+
{rows.map((r, ri) => (
|
|
1613
|
+
<div key={r.id ?? ri} className="border border-ink/10 p-2 space-y-2">
|
|
1614
|
+
<div className="flex items-center justify-between">
|
|
1615
|
+
<span className="text-[0.65rem] uppercase tracking-widest text-ink/45">
|
|
1616
|
+
Row {ri + 1}
|
|
1617
|
+
{r.hidden ? " · hidden" : ""}
|
|
1618
|
+
</span>
|
|
1619
|
+
<button
|
|
1620
|
+
onClick={() => setGroup(i, { ...g, rows: rows.filter((_, x) => x !== ri) })}
|
|
1621
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1622
|
+
title="Delete row"
|
|
1623
|
+
>
|
|
1624
|
+
<Trash2 className="size-3.5" />
|
|
1625
|
+
</button>
|
|
1626
|
+
</div>
|
|
1627
|
+
<label className="flex items-center gap-2 text-xs text-ink">
|
|
1628
|
+
<input
|
|
1629
|
+
type="checkbox"
|
|
1630
|
+
checked={r.hidden !== true}
|
|
1631
|
+
onChange={(e) => {
|
|
1632
|
+
const next = rows.slice();
|
|
1633
|
+
next[ri] = { ...r, hidden: !e.target.checked };
|
|
1634
|
+
setGroup(i, { ...g, rows: next });
|
|
1635
|
+
}}
|
|
1636
|
+
/>
|
|
1637
|
+
Show this row on the site
|
|
1638
|
+
</label>
|
|
1639
|
+
<LocalizedField
|
|
1640
|
+
label="Guests"
|
|
1641
|
+
value={r.label}
|
|
1642
|
+
onChange={(v) => {
|
|
1643
|
+
const next = rows.slice();
|
|
1644
|
+
next[ri] = { ...r, label: v };
|
|
1645
|
+
setGroup(i, { ...g, rows: next });
|
|
1646
|
+
}}
|
|
1647
|
+
/>
|
|
1648
|
+
<LocalizedField
|
|
1649
|
+
label="Price"
|
|
1650
|
+
value={r.price}
|
|
1651
|
+
onChange={(v) => {
|
|
1652
|
+
const next = rows.slice();
|
|
1653
|
+
next[ri] = { ...r, price: v };
|
|
1654
|
+
setGroup(i, { ...g, rows: next });
|
|
1655
|
+
}}
|
|
1656
|
+
/>
|
|
1657
|
+
</div>
|
|
1658
|
+
))}
|
|
1659
|
+
<button
|
|
1660
|
+
onClick={() => setGroup(i, { ...g, rows: [...rows, { id: uid("row") }] })}
|
|
1661
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1 text-[0.65rem] uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1662
|
+
>
|
|
1663
|
+
<Plus className="size-3" /> Add row
|
|
1664
|
+
</button>
|
|
1665
|
+
</div>
|
|
1666
|
+
</div>
|
|
1667
|
+
);
|
|
1668
|
+
})}
|
|
1669
|
+
</div>
|
|
1670
|
+
<button
|
|
1671
|
+
onClick={() => update({ ...p, groups: [...groups, { id: uid("grp") }] })}
|
|
1672
|
+
className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1673
|
+
>
|
|
1674
|
+
<Plus className="size-3.5" /> Add price card
|
|
1675
|
+
</button>
|
|
1676
|
+
</InspectorSection>
|
|
1677
|
+
</>
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/* ---------------- Checklist ---------------- */
|
|
1682
|
+
function IconGridInspector({ block, patch }: BlockPatchProps) {
|
|
1683
|
+
const p = (block.props ?? {}) as IconGridProps;
|
|
1684
|
+
const items = p.items ?? [];
|
|
1685
|
+
const update = (next: IconGridProps) => patch(next as unknown as BlockProps);
|
|
1686
|
+
const setItem = (i: number, item: IconGridItem) => {
|
|
1687
|
+
const next = items.slice();
|
|
1688
|
+
next[i] = item;
|
|
1689
|
+
update({ ...p, items: next });
|
|
1690
|
+
};
|
|
1691
|
+
const move = (i: number, dir: -1 | 1) => {
|
|
1692
|
+
const j = i + dir;
|
|
1693
|
+
if (j < 0 || j >= items.length) return;
|
|
1694
|
+
const next = items.slice();
|
|
1695
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
1696
|
+
update({ ...p, items: next });
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
return (
|
|
1700
|
+
<>
|
|
1701
|
+
<InspectorSection title="Heading">
|
|
1702
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1703
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1704
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
1705
|
+
<LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
|
|
1706
|
+
</InspectorSection>
|
|
1707
|
+
<InspectorSection title="Layout">
|
|
1708
|
+
<label className="block space-y-1.5">
|
|
1709
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Columns (desktop)</span>
|
|
1710
|
+
<select
|
|
1711
|
+
value={String(p.columns ?? 3)}
|
|
1712
|
+
onChange={(e) => update({ ...p, columns: Number(e.target.value) as IconGridProps["columns"] })}
|
|
1713
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1714
|
+
>
|
|
1715
|
+
{[2, 3, 4, 5].map((n) => (
|
|
1716
|
+
<option key={n} value={n}>{n} columns</option>
|
|
1717
|
+
))}
|
|
1718
|
+
</select>
|
|
1719
|
+
</label>
|
|
1720
|
+
<label className="block space-y-1.5">
|
|
1721
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
1722
|
+
<select
|
|
1723
|
+
value={p.tone ?? "white"}
|
|
1724
|
+
onChange={(e) => update({ ...p, tone: e.target.value as IconGridProps["tone"] })}
|
|
1725
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1726
|
+
>
|
|
1727
|
+
<option value="white">White</option>
|
|
1728
|
+
<option value="paper">Paper</option>
|
|
1729
|
+
<option value="ice">Ice</option>
|
|
1730
|
+
</select>
|
|
1731
|
+
</label>
|
|
1732
|
+
<label className="block space-y-1.5">
|
|
1733
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon style</span>
|
|
1734
|
+
<select
|
|
1735
|
+
value={p.iconStyle ?? "circle"}
|
|
1736
|
+
onChange={(e) => update({ ...p, iconStyle: e.target.value as IconGridProps["iconStyle"] })}
|
|
1737
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1738
|
+
>
|
|
1739
|
+
<option value="circle">Outline circle</option>
|
|
1740
|
+
<option value="plain">Plain icon</option>
|
|
1741
|
+
</select>
|
|
1742
|
+
</label>
|
|
1743
|
+
<label className="block space-y-1.5">
|
|
1744
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon position</span>
|
|
1745
|
+
<select
|
|
1746
|
+
value={p.iconAlign ?? "center"}
|
|
1747
|
+
onChange={(e) => update({ ...p, iconAlign: e.target.value as IconGridProps["iconAlign"] })}
|
|
1748
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1749
|
+
>
|
|
1750
|
+
<option value="left">Top left</option>
|
|
1751
|
+
<option value="center">Top centre</option>
|
|
1752
|
+
<option value="right">Top right</option>
|
|
1753
|
+
</select>
|
|
1754
|
+
</label>
|
|
1755
|
+
<label className="block space-y-1.5">
|
|
1756
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Card style</span>
|
|
1757
|
+
<select
|
|
1758
|
+
value={p.cardStyle ?? "bordered"}
|
|
1759
|
+
onChange={(e) => update({ ...p, cardStyle: e.target.value as IconGridProps["cardStyle"] })}
|
|
1760
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1761
|
+
>
|
|
1762
|
+
<option value="bordered">Bordered cards</option>
|
|
1763
|
+
<option value="bare">Bare (no cards)</option>
|
|
1764
|
+
</select>
|
|
1765
|
+
</label>
|
|
1766
|
+
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1767
|
+
</InspectorSection>
|
|
1768
|
+
<InspectorSection title={`Cards (${items.length})`}>
|
|
1769
|
+
<div className="space-y-4">
|
|
1770
|
+
{items.map((item, i) => {
|
|
1771
|
+
const Icon = resolveBlockIcon(item.icon);
|
|
1772
|
+
return (
|
|
1773
|
+
<div key={item.id ?? i} className="border border-ink/10 p-3 space-y-3">
|
|
1774
|
+
<div className="flex items-center justify-between">
|
|
1775
|
+
<span className="flex items-center gap-2 text-[0.7rem] uppercase tracking-widest text-ink/50">
|
|
1776
|
+
<Icon className="size-4 text-sea" />#{i + 1}
|
|
1777
|
+
</span>
|
|
1778
|
+
<div className="flex items-center gap-1">
|
|
1779
|
+
<button onClick={() => move(i, -1)} className="p-1 text-ink/50 hover:text-ink" title="Move up">
|
|
1780
|
+
<ArrowUp className="size-3.5" />
|
|
1781
|
+
</button>
|
|
1782
|
+
<button onClick={() => move(i, 1)} className="p-1 text-ink/50 hover:text-ink" title="Move down">
|
|
1783
|
+
<ArrowDown className="size-3.5" />
|
|
1784
|
+
</button>
|
|
1785
|
+
<button
|
|
1786
|
+
onClick={() => {
|
|
1787
|
+
const next = items.slice();
|
|
1788
|
+
next.splice(i, 1);
|
|
1789
|
+
update({ ...p, items: next });
|
|
1790
|
+
}}
|
|
1791
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1792
|
+
title="Delete"
|
|
1793
|
+
>
|
|
1794
|
+
<Trash2 className="size-3.5" />
|
|
1795
|
+
</button>
|
|
1796
|
+
</div>
|
|
1797
|
+
</div>
|
|
1798
|
+
<label className="block space-y-1.5">
|
|
1799
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Icon</span>
|
|
1800
|
+
<select
|
|
1801
|
+
value={item.icon ?? "sparkles"}
|
|
1802
|
+
onChange={(e) => setItem(i, { ...item, icon: e.target.value })}
|
|
1803
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1804
|
+
>
|
|
1805
|
+
{BLOCK_ICON_NAMES.map((name) => (
|
|
1806
|
+
<option key={name} value={name}>{name}</option>
|
|
1807
|
+
))}
|
|
1808
|
+
</select>
|
|
1809
|
+
</label>
|
|
1810
|
+
<LocalizedField label="Title" value={item.title} onChange={(v) => setItem(i, { ...item, title: v })} />
|
|
1811
|
+
<LocalizedField label="Meta (e.g. price)" value={item.meta} onChange={(v) => setItem(i, { ...item, meta: v })} />
|
|
1812
|
+
<LocalizedField label="Text" multiline value={item.body} onChange={(v) => setItem(i, { ...item, body: v })} />
|
|
1813
|
+
<TextField label="Link (optional)" value={item.href} onChange={(v) => setItem(i, { ...item, href: v })} />
|
|
1814
|
+
</div>
|
|
1815
|
+
);
|
|
1816
|
+
})}
|
|
1817
|
+
</div>
|
|
1818
|
+
<button
|
|
1819
|
+
onClick={() => update({ ...p, items: [...items, { id: uid("icon"), icon: "sparkles" }] })}
|
|
1820
|
+
className="mt-3 inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs uppercase tracking-[0.18em] text-ink hover:border-sea"
|
|
1821
|
+
>
|
|
1822
|
+
<Plus className="size-3.5" /> Add card
|
|
1823
|
+
</button>
|
|
1824
|
+
</InspectorSection>
|
|
1825
|
+
</>
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function ChecklistInspector({ block, patch }: BlockPatchProps) {
|
|
1830
|
+
const p = (block.props ?? {}) as ChecklistProps;
|
|
1831
|
+
const columns = p.columns ?? [];
|
|
1832
|
+
const update = (next: ChecklistProps) => patch(next as unknown as BlockProps);
|
|
1833
|
+
const setCol = (i: number, col: ChecklistColumn) => {
|
|
1834
|
+
const next = columns.slice();
|
|
1835
|
+
next[i] = col;
|
|
1836
|
+
update({ ...p, columns: next });
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
return (
|
|
1840
|
+
<>
|
|
1841
|
+
<InspectorSection title="Heading">
|
|
1842
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1843
|
+
<LocalizedField label="Title" value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1844
|
+
<LocalizedField label="Note" multiline value={p.note} onChange={(v) => update({ ...p, note: v })} />
|
|
1845
|
+
<label className="block space-y-1.5">
|
|
1846
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
1847
|
+
<select
|
|
1848
|
+
value={p.tone ?? "white"}
|
|
1849
|
+
onChange={(e) => update({ ...p, tone: e.target.value as ChecklistProps["tone"] })}
|
|
1850
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1851
|
+
>
|
|
1852
|
+
<option value="white">White</option>
|
|
1853
|
+
<option value="paper">Paper</option>
|
|
1854
|
+
</select>
|
|
1855
|
+
</label>
|
|
1856
|
+
</InspectorSection>
|
|
1857
|
+
{columns.map((col, ci) => {
|
|
1858
|
+
const entries = col.entries ?? [];
|
|
1859
|
+
return (
|
|
1860
|
+
<InspectorSection key={col.id ?? ci} title={`Column ${ci + 1} (${entries.length} items)`}>
|
|
1861
|
+
<LocalizedField label="Column title" value={col.title} onChange={(v) => setCol(ci, { ...col, title: v })} />
|
|
1862
|
+
<div className="space-y-2">
|
|
1863
|
+
{entries.map((e, i) => (
|
|
1864
|
+
<div key={e.id ?? i} className="border border-ink/10 p-3 space-y-2">
|
|
1865
|
+
<div className="flex items-center justify-between">
|
|
1866
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
1867
|
+
<button
|
|
1868
|
+
onClick={() => {
|
|
1869
|
+
const next = entries.slice();
|
|
1870
|
+
next.splice(i, 1);
|
|
1871
|
+
setCol(ci, { ...col, entries: next });
|
|
1872
|
+
}}
|
|
1873
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
1874
|
+
title="Delete"
|
|
1875
|
+
>
|
|
1876
|
+
<Trash2 className="size-3.5" />
|
|
1877
|
+
</button>
|
|
1878
|
+
</div>
|
|
1879
|
+
<LocalizedField
|
|
1880
|
+
label="Item"
|
|
1881
|
+
value={e.text}
|
|
1882
|
+
onChange={(v) => {
|
|
1883
|
+
const next = entries.slice();
|
|
1884
|
+
next[i] = { ...e, text: v };
|
|
1885
|
+
setCol(ci, { ...col, entries: next });
|
|
1886
|
+
}}
|
|
1887
|
+
/>
|
|
1888
|
+
</div>
|
|
1889
|
+
))}
|
|
1890
|
+
</div>
|
|
1891
|
+
<div className="flex items-center gap-2">
|
|
1892
|
+
<button
|
|
1893
|
+
onClick={() => setCol(ci, { ...col, entries: [...entries, { id: uid("i"), text: {} }] })}
|
|
1894
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
1895
|
+
>
|
|
1896
|
+
<Plus className="size-4" /> Add item
|
|
1897
|
+
</button>
|
|
1898
|
+
<button
|
|
1899
|
+
onClick={() => {
|
|
1900
|
+
const next = columns.slice();
|
|
1901
|
+
next.splice(ci, 1);
|
|
1902
|
+
update({ ...p, columns: next });
|
|
1903
|
+
}}
|
|
1904
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
|
|
1905
|
+
>
|
|
1906
|
+
<Trash2 className="size-3.5" /> Remove column
|
|
1907
|
+
</button>
|
|
1908
|
+
</div>
|
|
1909
|
+
</InspectorSection>
|
|
1910
|
+
);
|
|
1911
|
+
})}
|
|
1912
|
+
<button
|
|
1913
|
+
onClick={() => update({ ...p, columns: [...columns, { id: uid("c"), title: {}, entries: [] }] })}
|
|
1914
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
1915
|
+
>
|
|
1916
|
+
<Plus className="size-4" /> Add column
|
|
1917
|
+
</button>
|
|
1918
|
+
</>
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/* ---------------- CTA banner ---------------- */
|
|
1923
|
+
function CtaBannerInspector({ block, patch }: BlockPatchProps) {
|
|
1924
|
+
const p = (block.props ?? {}) as CtaBannerProps;
|
|
1925
|
+
const update = (next: CtaBannerProps) => patch(next as unknown as BlockProps);
|
|
1926
|
+
|
|
1927
|
+
return (
|
|
1928
|
+
<>
|
|
1929
|
+
<InspectorSection title="Content">
|
|
1930
|
+
<LocalizedField label="Eyebrow" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
1931
|
+
<LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
1932
|
+
<LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
|
|
1933
|
+
<label className="block space-y-1.5">
|
|
1934
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">Background</span>
|
|
1935
|
+
<select
|
|
1936
|
+
value={p.tone ?? "ink"}
|
|
1937
|
+
onChange={(e) => update({ ...p, tone: e.target.value as CtaBannerProps["tone"] })}
|
|
1938
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
1939
|
+
>
|
|
1940
|
+
<option value="ink">Dark</option>
|
|
1941
|
+
<option value="sea">Ice</option>
|
|
1942
|
+
<option value="paper">Paper</option>
|
|
1943
|
+
</select>
|
|
1944
|
+
</label>
|
|
1945
|
+
</InspectorSection>
|
|
1946
|
+
<InspectorSection title="Primary button">
|
|
1947
|
+
<LocalizedField label="Label" value={p.cta?.label} onChange={(v) => update({ ...p, cta: { ...p.cta, label: v } })} />
|
|
1948
|
+
<TextField label="URL" value={p.cta?.href ?? ""} onChange={(v) => update({ ...p, cta: { ...p.cta, href: v } })} />
|
|
1949
|
+
</InspectorSection>
|
|
1950
|
+
<InspectorSection title="Secondary button">
|
|
1951
|
+
<LocalizedField label="Label" value={p.secondaryCta?.label} onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, label: v } })} />
|
|
1952
|
+
<TextField label="URL" value={p.secondaryCta?.href ?? ""} onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })} />
|
|
1953
|
+
</InspectorSection>
|
|
1954
|
+
</>
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
/* ---------------- Image or video — Left/Right ---------------- */
|
|
1960
|
+
const MEDIA_SPLIT_STYLES: Record<
|
|
1961
|
+
NonNullable<MediaSplitProps["style"]>,
|
|
1962
|
+
{ label: string; preset: Partial<MediaSplitProps> }
|
|
1963
|
+
> = {
|
|
1964
|
+
editorial: {
|
|
1965
|
+
label: "Editorial (default)",
|
|
1966
|
+
preset: { tone: "white", padding: "normal", mediaRatio: "50", aspect: "landscape", rounded: false, fullBleed: false, align: "center" },
|
|
1967
|
+
},
|
|
1968
|
+
compact: {
|
|
1969
|
+
label: "Compact",
|
|
1970
|
+
preset: { tone: "paper", padding: "compact", mediaRatio: "40", aspect: "square", rounded: false, fullBleed: false, align: "center" },
|
|
1971
|
+
},
|
|
1972
|
+
fullBleed: {
|
|
1973
|
+
label: "Full bleed",
|
|
1974
|
+
preset: { tone: "white", padding: "compact", mediaRatio: "60", aspect: "wide", rounded: false, fullBleed: true, align: "center" },
|
|
1975
|
+
},
|
|
1976
|
+
dark: {
|
|
1977
|
+
label: "Dark",
|
|
1978
|
+
preset: { tone: "ink", padding: "spacious", mediaRatio: "50", aspect: "portrait", rounded: true, fullBleed: false, align: "center" },
|
|
1979
|
+
},
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
function Select<T extends string>({
|
|
1983
|
+
label,
|
|
1984
|
+
value,
|
|
1985
|
+
options,
|
|
1986
|
+
onChange,
|
|
1987
|
+
}: {
|
|
1988
|
+
label: string;
|
|
1989
|
+
value: T;
|
|
1990
|
+
options: { value: T; label: string }[];
|
|
1991
|
+
onChange: (v: T) => void;
|
|
1992
|
+
}) {
|
|
1993
|
+
return (
|
|
1994
|
+
<label className="block space-y-1.5">
|
|
1995
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
1996
|
+
<select
|
|
1997
|
+
value={value}
|
|
1998
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
1999
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
2000
|
+
>
|
|
2001
|
+
{options.map((o) => (
|
|
2002
|
+
<option key={o.value} value={o.value}>
|
|
2003
|
+
{o.label}
|
|
2004
|
+
</option>
|
|
2005
|
+
))}
|
|
2006
|
+
</select>
|
|
2007
|
+
</label>
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function Toggle({
|
|
2012
|
+
label,
|
|
2013
|
+
checked,
|
|
2014
|
+
onChange,
|
|
2015
|
+
}: {
|
|
2016
|
+
label: string;
|
|
2017
|
+
checked: boolean;
|
|
2018
|
+
onChange: (v: boolean) => void;
|
|
2019
|
+
}) {
|
|
2020
|
+
return (
|
|
2021
|
+
<label className="flex items-center gap-2 text-sm text-ink">
|
|
2022
|
+
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
2023
|
+
{label}
|
|
2024
|
+
</label>
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function MediaSplitInspector({ block, patch }: BlockPatchProps) {
|
|
2029
|
+
const p = (block.props ?? {}) as MediaSplitProps;
|
|
2030
|
+
const update = (next: MediaSplitProps) => patch(next as unknown as BlockProps);
|
|
2031
|
+
const slides = p.slides ?? [];
|
|
2032
|
+
const bullets = p.bullets ?? [];
|
|
2033
|
+
const setSlide = (i: number, s: MediaSplitSlide) => {
|
|
2034
|
+
const next = slides.slice();
|
|
2035
|
+
next[i] = s;
|
|
2036
|
+
update({ ...p, slides: next });
|
|
2037
|
+
};
|
|
2038
|
+
const moveSlide = (i: number, dir: -1 | 1) => {
|
|
2039
|
+
const j = i + dir;
|
|
2040
|
+
if (j < 0 || j >= slides.length) return;
|
|
2041
|
+
const next = slides.slice();
|
|
2042
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
2043
|
+
update({ ...p, slides: next });
|
|
2044
|
+
};
|
|
2045
|
+
const setBullet = (i: number, b: MediaSplitBullet) => {
|
|
2046
|
+
const next = bullets.slice();
|
|
2047
|
+
next[i] = b;
|
|
2048
|
+
update({ ...p, bullets: next });
|
|
2049
|
+
};
|
|
2050
|
+
const mediaType = p.mediaType ?? "image";
|
|
2051
|
+
|
|
2052
|
+
return (
|
|
2053
|
+
<>
|
|
2054
|
+
<InspectorSection title="Style">
|
|
2055
|
+
<Select
|
|
2056
|
+
label="Preset"
|
|
2057
|
+
value={p.style ?? "editorial"}
|
|
2058
|
+
options={(Object.keys(MEDIA_SPLIT_STYLES) as NonNullable<MediaSplitProps["style"]>[]).map((k) => ({
|
|
2059
|
+
value: k,
|
|
2060
|
+
label: MEDIA_SPLIT_STYLES[k].label,
|
|
2061
|
+
}))}
|
|
2062
|
+
onChange={(v) => update({ ...p, style: v, ...MEDIA_SPLIT_STYLES[v].preset })}
|
|
2063
|
+
/>
|
|
2064
|
+
<p className="text-xs text-ink/50">
|
|
2065
|
+
Picking a preset applies its layout defaults — you can still change every setting below.
|
|
2066
|
+
</p>
|
|
2067
|
+
</InspectorSection>
|
|
2068
|
+
|
|
2069
|
+
<InspectorSection title="Content">
|
|
2070
|
+
<LocalizedField label="Eyebrow / subtitle" value={p.eyebrow} onChange={(v) => update({ ...p, eyebrow: v })} />
|
|
2071
|
+
<LocalizedField label="Title" multiline value={p.title} onChange={(v) => update({ ...p, title: v })} />
|
|
2072
|
+
<LocalizedField label="Lead" multiline value={p.lead} onChange={(v) => update({ ...p, lead: v })} />
|
|
2073
|
+
<LocalizedField label="Body" multiline value={p.body} onChange={(v) => update({ ...p, body: v })} />
|
|
2074
|
+
</InspectorSection>
|
|
2075
|
+
|
|
2076
|
+
<InspectorSection title={`Bullets (${bullets.length})`}>
|
|
2077
|
+
{bullets.map((b, i) => (
|
|
2078
|
+
<div key={b.id ?? i} className="border border-ink/10 p-3 space-y-2">
|
|
2079
|
+
<div className="flex items-center justify-between">
|
|
2080
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
2081
|
+
<button
|
|
2082
|
+
onClick={() => {
|
|
2083
|
+
const next = bullets.slice();
|
|
2084
|
+
next.splice(i, 1);
|
|
2085
|
+
update({ ...p, bullets: next });
|
|
2086
|
+
}}
|
|
2087
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
2088
|
+
title="Delete"
|
|
2089
|
+
>
|
|
2090
|
+
<Trash2 className="size-3.5" />
|
|
2091
|
+
</button>
|
|
2092
|
+
</div>
|
|
2093
|
+
<LocalizedField label="Text" value={b.text} onChange={(v) => setBullet(i, { ...b, text: v })} />
|
|
2094
|
+
</div>
|
|
2095
|
+
))}
|
|
2096
|
+
<button
|
|
2097
|
+
onClick={() => update({ ...p, bullets: [...bullets, { id: uid("b"), text: {} }] })}
|
|
2098
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
2099
|
+
>
|
|
2100
|
+
<Plus className="size-4" /> Add bullet
|
|
2101
|
+
</button>
|
|
2102
|
+
</InspectorSection>
|
|
2103
|
+
|
|
2104
|
+
<InspectorSection title="Buttons">
|
|
2105
|
+
<LocalizedField
|
|
2106
|
+
label="Primary label"
|
|
2107
|
+
value={p.cta?.label}
|
|
2108
|
+
onChange={(v) => update({ ...p, cta: { ...(p.cta ?? {}), label: v } })}
|
|
2109
|
+
/>
|
|
2110
|
+
<TextField
|
|
2111
|
+
label="Primary link"
|
|
2112
|
+
value={p.cta?.href ?? ""}
|
|
2113
|
+
onChange={(v) => update({ ...p, cta: { ...(p.cta ?? {}), href: v } })}
|
|
2114
|
+
/>
|
|
2115
|
+
<LocalizedField
|
|
2116
|
+
label="Secondary label"
|
|
2117
|
+
value={p.secondaryCta?.label}
|
|
2118
|
+
onChange={(v) => update({ ...p, secondaryCta: { ...(p.secondaryCta ?? {}), label: v } })}
|
|
2119
|
+
/>
|
|
2120
|
+
<TextField
|
|
2121
|
+
label="Secondary link"
|
|
2122
|
+
value={p.secondaryCta?.href ?? ""}
|
|
2123
|
+
onChange={(v) => update({ ...p, secondaryCta: { ...(p.secondaryCta ?? {}), href: v } })}
|
|
2124
|
+
/>
|
|
2125
|
+
</InspectorSection>
|
|
2126
|
+
|
|
2127
|
+
<InspectorSection title="Layout">
|
|
2128
|
+
<Select
|
|
2129
|
+
label="Media side (desktop)"
|
|
2130
|
+
value={p.mediaSide ?? "left"}
|
|
2131
|
+
options={[
|
|
2132
|
+
{ value: "left", label: "Media left / text right" },
|
|
2133
|
+
{ value: "right", label: "Text left / media right" },
|
|
2134
|
+
]}
|
|
2135
|
+
onChange={(v) => update({ ...p, mediaSide: v })}
|
|
2136
|
+
/>
|
|
2137
|
+
<Select
|
|
2138
|
+
label="Media width"
|
|
2139
|
+
value={p.mediaRatio ?? "50"}
|
|
2140
|
+
options={[
|
|
2141
|
+
{ value: "40", label: "40%" },
|
|
2142
|
+
{ value: "50", label: "50%" },
|
|
2143
|
+
{ value: "60", label: "60%" },
|
|
2144
|
+
]}
|
|
2145
|
+
onChange={(v) => update({ ...p, mediaRatio: v })}
|
|
2146
|
+
/>
|
|
2147
|
+
<Select
|
|
2148
|
+
label="Vertical alignment"
|
|
2149
|
+
value={p.align ?? "center"}
|
|
2150
|
+
options={[
|
|
2151
|
+
{ value: "center", label: "Center" },
|
|
2152
|
+
{ value: "top", label: "Top" },
|
|
2153
|
+
]}
|
|
2154
|
+
onChange={(v) => update({ ...p, align: v })}
|
|
2155
|
+
/>
|
|
2156
|
+
<Select
|
|
2157
|
+
label="Background"
|
|
2158
|
+
value={p.tone ?? "white"}
|
|
2159
|
+
options={[
|
|
2160
|
+
{ value: "white", label: "White" },
|
|
2161
|
+
{ value: "paper", label: "Paper" },
|
|
2162
|
+
{ value: "sea", label: "Ice" },
|
|
2163
|
+
{ value: "ink", label: "Ink (dark)" },
|
|
2164
|
+
]}
|
|
2165
|
+
onChange={(v) => update({ ...p, tone: v })}
|
|
2166
|
+
/>
|
|
2167
|
+
<Select
|
|
2168
|
+
label="Spacing"
|
|
2169
|
+
value={p.padding ?? "normal"}
|
|
2170
|
+
options={[
|
|
2171
|
+
{ value: "compact", label: "Compact" },
|
|
2172
|
+
{ value: "normal", label: "Normal" },
|
|
2173
|
+
{ value: "spacious", label: "Spacious" },
|
|
2174
|
+
]}
|
|
2175
|
+
onChange={(v) => update({ ...p, padding: v })}
|
|
2176
|
+
/>
|
|
2177
|
+
<Select
|
|
2178
|
+
label="Media aspect"
|
|
2179
|
+
value={p.aspect ?? "landscape"}
|
|
2180
|
+
options={[
|
|
2181
|
+
{ value: "landscape", label: "Landscape 4:3" },
|
|
2182
|
+
{ value: "wide", label: "Wide 16:9" },
|
|
2183
|
+
{ value: "square", label: "Square" },
|
|
2184
|
+
{ value: "portrait", label: "Portrait 4:5" },
|
|
2185
|
+
{ value: "auto", label: "Auto (natural)" },
|
|
2186
|
+
]}
|
|
2187
|
+
onChange={(v) => update({ ...p, aspect: v })}
|
|
2188
|
+
/>
|
|
2189
|
+
<Select
|
|
2190
|
+
label="Media backdrop"
|
|
2191
|
+
value={p.mediaBg ?? "tint"}
|
|
2192
|
+
options={[
|
|
2193
|
+
{ value: "tint", label: "Light tint" },
|
|
2194
|
+
{ value: "transparent", label: "Transparent (blend with section)" },
|
|
2195
|
+
]}
|
|
2196
|
+
onChange={(v) => update({ ...p, mediaBg: v })}
|
|
2197
|
+
/>
|
|
2198
|
+
<Select
|
|
2199
|
+
label="Media fit"
|
|
2200
|
+
value={p.mediaFit ?? "cover"}
|
|
2201
|
+
options={[
|
|
2202
|
+
{ value: "cover", label: "Fill frame (crop)" },
|
|
2203
|
+
{ value: "contain", label: "Fit inside (logos)" },
|
|
2204
|
+
]}
|
|
2205
|
+
onChange={(v) => update({ ...p, mediaFit: v })}
|
|
2206
|
+
/>
|
|
2207
|
+
<Toggle label="Full-bleed width" checked={Boolean(p.fullBleed)} onChange={(v) => update({ ...p, fullBleed: v })} />
|
|
2208
|
+
<Toggle label="Rounded media corners" checked={Boolean(p.rounded)} onChange={(v) => update({ ...p, rounded: v })} />
|
|
2209
|
+
<TextField label="Anchor id (optional)" value={p.anchor ?? ""} onChange={(v) => update({ ...p, anchor: v })} />
|
|
2210
|
+
<p className="text-xs text-ink/50">
|
|
2211
|
+
On mobile the order is always: subtitle and title, then media, then the remaining text.
|
|
2212
|
+
</p>
|
|
2213
|
+
</InspectorSection>
|
|
2214
|
+
|
|
2215
|
+
<InspectorSection title="Media">
|
|
2216
|
+
<Select
|
|
2217
|
+
label="Media type"
|
|
2218
|
+
value={mediaType}
|
|
2219
|
+
options={[
|
|
2220
|
+
{ value: "image", label: "Single image" },
|
|
2221
|
+
{ value: "slideshow", label: "Image slideshow" },
|
|
2222
|
+
{ value: "video", label: "Video" },
|
|
2223
|
+
]}
|
|
2224
|
+
onChange={(v) => update({ ...p, mediaType: v })}
|
|
2225
|
+
/>
|
|
2226
|
+
{mediaType === "image" ? (
|
|
2227
|
+
<>
|
|
2228
|
+
<MediaField label="Image" accept="image" value={p.image} onChange={(url) => update({ ...p, image: url })} />
|
|
2229
|
+
<LocalizedField label="Image alt text" value={p.imageAlt} onChange={(v) => update({ ...p, imageAlt: v })} />
|
|
2230
|
+
</>
|
|
2231
|
+
) : null}
|
|
2232
|
+
{mediaType === "video" ? (
|
|
2233
|
+
<>
|
|
2234
|
+
<MediaField label="Video" accept="video" value={p.videoSrc} onChange={(url) => update({ ...p, videoSrc: url })} />
|
|
2235
|
+
<MediaField label="Poster image" accept="image" value={p.posterSrc} onChange={(url) => update({ ...p, posterSrc: url })} />
|
|
2236
|
+
<Toggle label="Autoplay" checked={p.videoAutoplay !== false} onChange={(v) => update({ ...p, videoAutoplay: v })} />
|
|
2237
|
+
<Toggle label="Loop" checked={p.videoLoop !== false} onChange={(v) => update({ ...p, videoLoop: v })} />
|
|
2238
|
+
<Toggle label="Muted" checked={p.videoMuted !== false} onChange={(v) => update({ ...p, videoMuted: v })} />
|
|
2239
|
+
<Toggle label="Show controls" checked={Boolean(p.videoControls)} onChange={(v) => update({ ...p, videoControls: v })} />
|
|
2240
|
+
</>
|
|
2241
|
+
) : null}
|
|
2242
|
+
</InspectorSection>
|
|
2243
|
+
|
|
2244
|
+
{mediaType === "slideshow" ? (
|
|
2245
|
+
<InspectorSection title={`Slides (${slides.length})`}>
|
|
2246
|
+
<Toggle label="Autoplay" checked={Boolean(p.autoplay)} onChange={(v) => update({ ...p, autoplay: v })} />
|
|
2247
|
+
<TextField
|
|
2248
|
+
label="Autoplay interval (ms)"
|
|
2249
|
+
value={String(p.intervalMs ?? 5000)}
|
|
2250
|
+
onChange={(v) => update({ ...p, intervalMs: Number(v) || 5000 })}
|
|
2251
|
+
/>
|
|
2252
|
+
<Toggle label="Show pagination dots" checked={p.showDots !== false} onChange={(v) => update({ ...p, showDots: v })} />
|
|
2253
|
+
<Toggle label="Show arrows" checked={p.showArrows !== false} onChange={(v) => update({ ...p, showArrows: v })} />
|
|
2254
|
+
<Toggle label="Loop" checked={p.loop !== false} onChange={(v) => update({ ...p, loop: v })} />
|
|
2255
|
+
<Toggle label="Show captions" checked={Boolean(p.showCaptions)} onChange={(v) => update({ ...p, showCaptions: v })} />
|
|
2256
|
+
{slides.map((s, i) => (
|
|
2257
|
+
<div key={s.id ?? i} className="border border-ink/10 p-3 space-y-2">
|
|
2258
|
+
<div className="flex items-center justify-between">
|
|
2259
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/50">#{i + 1}</span>
|
|
2260
|
+
<div className="flex items-center gap-1">
|
|
2261
|
+
<button onClick={() => moveSlide(i, -1)} className="p-1 text-ink/60 hover:text-ink" title="Move up">
|
|
2262
|
+
<ArrowUp className="size-3.5" />
|
|
2263
|
+
</button>
|
|
2264
|
+
<button onClick={() => moveSlide(i, 1)} className="p-1 text-ink/60 hover:text-ink" title="Move down">
|
|
2265
|
+
<ArrowDown className="size-3.5" />
|
|
2266
|
+
</button>
|
|
2267
|
+
<button
|
|
2268
|
+
onClick={() => {
|
|
2269
|
+
const next = slides.slice();
|
|
2270
|
+
next.splice(i, 1);
|
|
2271
|
+
update({ ...p, slides: next });
|
|
2272
|
+
}}
|
|
2273
|
+
className="p-1 text-red-500 hover:text-red-700"
|
|
2274
|
+
title="Delete"
|
|
2275
|
+
>
|
|
2276
|
+
<Trash2 className="size-3.5" />
|
|
2277
|
+
</button>
|
|
2278
|
+
</div>
|
|
2279
|
+
</div>
|
|
2280
|
+
<MediaField label="Image" accept="image" value={s.image} onChange={(url) => setSlide(i, { ...s, image: url })} />
|
|
2281
|
+
<LocalizedField label="Alt text" value={s.alt} onChange={(v) => setSlide(i, { ...s, alt: v })} />
|
|
2282
|
+
<LocalizedField label="Caption" value={s.caption} onChange={(v) => setSlide(i, { ...s, caption: v })} />
|
|
2283
|
+
<TextField label="Link (optional)" value={s.href ?? ""} onChange={(v) => setSlide(i, { ...s, href: v })} />
|
|
2284
|
+
</div>
|
|
2285
|
+
))}
|
|
2286
|
+
<button
|
|
2287
|
+
onClick={() => update({ ...p, slides: [...slides, { id: uid("s"), image: "", alt: {} }] })}
|
|
2288
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
2289
|
+
>
|
|
2290
|
+
<Plus className="size-4" /> Add slide
|
|
2291
|
+
</button>
|
|
2292
|
+
</InspectorSection>
|
|
2293
|
+
) : null}
|
|
2294
|
+
</>
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
export {
|
|
2300
|
+
HeroInspector,
|
|
2301
|
+
IntroInspector,
|
|
2302
|
+
FaqInspector,
|
|
2303
|
+
InstagramInspector,
|
|
2304
|
+
ProseInspector,
|
|
2305
|
+
RichTextInspector,
|
|
2306
|
+
HeroFullInspector,
|
|
2307
|
+
ImageMarqueeInspector,
|
|
2308
|
+
LinkTilesInspector,
|
|
2309
|
+
CabinTabsInspector,
|
|
2310
|
+
PriceTableInspector,
|
|
2311
|
+
IconGridInspector,
|
|
2312
|
+
ChecklistInspector,
|
|
2313
|
+
CtaBannerInspector,
|
|
2314
|
+
MediaSplitInspector,
|
|
2315
|
+
Select as InspectorSelect,
|
|
2316
|
+
Toggle as InspectorToggle,
|
|
2317
|
+
uid as inspectorUid,
|
|
2318
|
+
};
|