@jimmy_harika/websitekit 0.3.1 → 0.5.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 +1 -0
- package/package.json +2 -2
- package/src/blocks/back-to-top.tsx +25 -0
- package/src/blocks/banner-quote.tsx +89 -0
- package/src/blocks/category-showcase.tsx +107 -0
- package/src/blocks/hero-slideshow.tsx +151 -0
- package/src/blocks/index.ts +40 -0
- package/src/blocks/logo-band.tsx +62 -0
- package/src/blocks/nav-menu.tsx +105 -0
- package/src/blocks/nav.tsx +149 -0
- package/src/blocks/primitives.tsx +54 -0
- package/src/blocks/reveal.tsx +74 -0
- package/src/blocks/site-footer.tsx +190 -0
- package/src/blocks/slider.tsx +200 -0
- package/src/blocks/teaser-cards.tsx +77 -0
- package/src/blocks/testimonial-slider.tsx +167 -0
- package/src/editor/Inspector.tsx +285 -0
- package/src/model.ts +8 -0
- package/src/registry.ts +26 -0
- package/src/renderer/index.tsx +31 -20
- package/src/theme.ts +13 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeaserCardsBlock — 3-up internal-navigation cards (e.g. Weddings / Portraits
|
|
3
|
+
* / About) with outline buttons. Server-safe, edit-aware heading.
|
|
4
|
+
*/
|
|
5
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
6
|
+
import {
|
|
7
|
+
BODY,
|
|
8
|
+
HAIRLINE,
|
|
9
|
+
HEADING,
|
|
10
|
+
HEADING_CASE,
|
|
11
|
+
MUTED,
|
|
12
|
+
RADIUS,
|
|
13
|
+
Section,
|
|
14
|
+
TEXT,
|
|
15
|
+
TokenButton,
|
|
16
|
+
} from "./primitives";
|
|
17
|
+
|
|
18
|
+
export interface TeaserCardItem {
|
|
19
|
+
title: string;
|
|
20
|
+
sub?: string;
|
|
21
|
+
href: string;
|
|
22
|
+
buttonLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TeaserCardsBlockProps {
|
|
26
|
+
heading?: string;
|
|
27
|
+
items?: TeaserCardItem[];
|
|
28
|
+
edit?: EditPrimitives;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function TeaserCardsBlock({ heading, items = [], edit }: TeaserCardsBlockProps) {
|
|
32
|
+
return (
|
|
33
|
+
<Section>
|
|
34
|
+
{(heading || edit) &&
|
|
35
|
+
renderText(edit, {
|
|
36
|
+
field: "heading",
|
|
37
|
+
value: heading ?? "",
|
|
38
|
+
as: "h2",
|
|
39
|
+
placeholder: "Where to next",
|
|
40
|
+
className: "mb-10 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
|
|
41
|
+
style: { fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE },
|
|
42
|
+
})}
|
|
43
|
+
{items.length === 0 ? (
|
|
44
|
+
<p className="py-8 text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
|
|
45
|
+
Add teaser cards (title, line, link).
|
|
46
|
+
</p>
|
|
47
|
+
) : (
|
|
48
|
+
<div className="grid gap-5 md:grid-cols-3">
|
|
49
|
+
{items.map((item, i) => (
|
|
50
|
+
<div
|
|
51
|
+
key={i}
|
|
52
|
+
className="flex flex-col items-center gap-4 px-8 py-12 text-center"
|
|
53
|
+
style={{ border: `1px solid ${HAIRLINE}`, borderRadius: RADIUS }}
|
|
54
|
+
>
|
|
55
|
+
<h3
|
|
56
|
+
className="text-[clamp(1.25rem,2.5vw,1.75rem)] font-light"
|
|
57
|
+
style={{ fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE }}
|
|
58
|
+
>
|
|
59
|
+
{item.title}
|
|
60
|
+
</h3>
|
|
61
|
+
{item.sub && (
|
|
62
|
+
<p className="max-w-xs text-[15px] leading-relaxed" style={{ fontFamily: BODY, color: MUTED }}>
|
|
63
|
+
{item.sub}
|
|
64
|
+
</p>
|
|
65
|
+
)}
|
|
66
|
+
<div className="mt-auto pt-4">
|
|
67
|
+
<TokenButton href={item.href} outline>
|
|
68
|
+
{item.buttonLabel || "Explore"}
|
|
69
|
+
</TokenButton>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</Section>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestimonialSliderBlock — kind words with three treatments:
|
|
3
|
+
* - "overlay": full-bleed photo behind each quote, crossfade slider
|
|
4
|
+
* - "split-panel": quote panel left / portrait right, both swap together
|
|
5
|
+
* - "band": static tinted band with up to three quotes (no slider)
|
|
6
|
+
* Server-safe; sliding variants reuse the shared <Slider> island.
|
|
7
|
+
*/
|
|
8
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
9
|
+
import { ACCENT, BODY, HEADING, MUTED, RADIUS, Section, TEXT, TINT } from "./primitives";
|
|
10
|
+
import { Slider } from "./slider";
|
|
11
|
+
|
|
12
|
+
export interface TestimonialSlideItem {
|
|
13
|
+
quote: string;
|
|
14
|
+
name: string;
|
|
15
|
+
image?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TestimonialSliderBlockProps {
|
|
19
|
+
heading?: string;
|
|
20
|
+
items?: TestimonialSlideItem[];
|
|
21
|
+
variant?: "overlay" | "split-panel" | "band";
|
|
22
|
+
edit?: EditPrimitives;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function TestimonialSliderBlock({
|
|
26
|
+
heading,
|
|
27
|
+
items = [],
|
|
28
|
+
variant = "split-panel",
|
|
29
|
+
edit,
|
|
30
|
+
}: TestimonialSliderBlockProps) {
|
|
31
|
+
if (items.length === 0) {
|
|
32
|
+
return (
|
|
33
|
+
<Section>
|
|
34
|
+
<p className="py-8 text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
|
|
35
|
+
Add testimonials (quote, name, optional photo).
|
|
36
|
+
</p>
|
|
37
|
+
</Section>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headingEl =
|
|
42
|
+
heading || edit
|
|
43
|
+
? renderText(edit, {
|
|
44
|
+
field: "heading",
|
|
45
|
+
value: heading ?? "",
|
|
46
|
+
as: "h2",
|
|
47
|
+
placeholder: "Kind words",
|
|
48
|
+
className: "mb-10 text-center text-[11px] uppercase tracking-[0.3em]",
|
|
49
|
+
style: { fontFamily: BODY, color: ACCENT },
|
|
50
|
+
})
|
|
51
|
+
: null;
|
|
52
|
+
|
|
53
|
+
if (variant === "overlay") {
|
|
54
|
+
return (
|
|
55
|
+
<Slider
|
|
56
|
+
variant="fade"
|
|
57
|
+
arrows="overlay"
|
|
58
|
+
label={heading || "Testimonials"}
|
|
59
|
+
slides={items.map((item, i) => (
|
|
60
|
+
<div key={i} className="relative">
|
|
61
|
+
{item.image && (
|
|
62
|
+
<div className="absolute inset-0">
|
|
63
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
64
|
+
<img src={item.image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
<div
|
|
68
|
+
className="absolute inset-0"
|
|
69
|
+
style={{ background: item.image ? "rgba(0,0,0,0.55)" : TINT }}
|
|
70
|
+
/>
|
|
71
|
+
<figure className="relative flex min-h-[70vh] flex-col items-center justify-center px-8 py-20 text-center sm:px-16">
|
|
72
|
+
<blockquote
|
|
73
|
+
className="max-w-3xl text-[clamp(1.3rem,3vw,2rem)] font-light italic leading-snug"
|
|
74
|
+
style={{ fontFamily: HEADING, color: item.image ? "#ffffff" : TEXT }}
|
|
75
|
+
>
|
|
76
|
+
“{item.quote}”
|
|
77
|
+
</blockquote>
|
|
78
|
+
<figcaption
|
|
79
|
+
className="mt-8 text-[11px] uppercase tracking-[0.3em]"
|
|
80
|
+
style={{ fontFamily: BODY, color: item.image ? "rgba(255,255,255,0.75)" : MUTED }}
|
|
81
|
+
>
|
|
82
|
+
{item.name}
|
|
83
|
+
</figcaption>
|
|
84
|
+
</figure>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (variant === "split-panel") {
|
|
92
|
+
return (
|
|
93
|
+
<Section>
|
|
94
|
+
{headingEl}
|
|
95
|
+
<Slider
|
|
96
|
+
variant="fade"
|
|
97
|
+
arrows="below"
|
|
98
|
+
label={heading || "Testimonials"}
|
|
99
|
+
slides={items.map((item, i) => (
|
|
100
|
+
<div key={i} className="grid items-stretch gap-0 md:grid-cols-2">
|
|
101
|
+
<figure
|
|
102
|
+
className="flex flex-col justify-center px-8 py-14 sm:px-12"
|
|
103
|
+
style={{ background: TINT, borderRadius: RADIUS }}
|
|
104
|
+
>
|
|
105
|
+
<blockquote
|
|
106
|
+
className="text-[clamp(1.15rem,2.4vw,1.6rem)] font-light italic leading-snug"
|
|
107
|
+
style={{ fontFamily: HEADING, color: TEXT }}
|
|
108
|
+
>
|
|
109
|
+
“{item.quote}”
|
|
110
|
+
</blockquote>
|
|
111
|
+
<figcaption
|
|
112
|
+
className="mt-6 text-[11px] uppercase tracking-[0.3em]"
|
|
113
|
+
style={{ fontFamily: BODY, color: MUTED }}
|
|
114
|
+
>
|
|
115
|
+
{item.name}
|
|
116
|
+
</figcaption>
|
|
117
|
+
</figure>
|
|
118
|
+
<div
|
|
119
|
+
className="hidden aspect-[4/3] w-full overflow-hidden md:block md:aspect-auto"
|
|
120
|
+
style={{ borderRadius: RADIUS }}
|
|
121
|
+
>
|
|
122
|
+
{item.image && (
|
|
123
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
124
|
+
<img src={item.image} alt={item.name} loading="lazy" className="h-full w-full object-cover" />
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
/>
|
|
130
|
+
</Section>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// band — static, up to three quotes.
|
|
135
|
+
return (
|
|
136
|
+
<Section tint>
|
|
137
|
+
{headingEl}
|
|
138
|
+
<div className="grid gap-10 md:grid-cols-3">
|
|
139
|
+
{items.slice(0, 3).map((item, i) => (
|
|
140
|
+
<figure key={i} className="text-center">
|
|
141
|
+
{item.image && (
|
|
142
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
143
|
+
<img
|
|
144
|
+
src={item.image}
|
|
145
|
+
alt={item.name}
|
|
146
|
+
loading="lazy"
|
|
147
|
+
className="mx-auto mb-5 size-14 rounded-full object-cover"
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
<blockquote
|
|
151
|
+
className="text-[15px] font-light italic leading-relaxed"
|
|
152
|
+
style={{ fontFamily: HEADING, color: TEXT }}
|
|
153
|
+
>
|
|
154
|
+
“{item.quote}”
|
|
155
|
+
</blockquote>
|
|
156
|
+
<figcaption
|
|
157
|
+
className="mt-4 text-[11px] uppercase tracking-[0.3em]"
|
|
158
|
+
style={{ fontFamily: BODY, color: MUTED }}
|
|
159
|
+
>
|
|
160
|
+
{item.name}
|
|
161
|
+
</figcaption>
|
|
162
|
+
</figure>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</Section>
|
|
166
|
+
);
|
|
167
|
+
}
|
package/src/editor/Inspector.tsx
CHANGED
|
@@ -237,6 +237,22 @@ function Field({
|
|
|
237
237
|
}
|
|
238
238
|
onAltChange={(alt) => field.altKey && setProp(field.altKey, alt)}
|
|
239
239
|
/>
|
|
240
|
+
) : field.widget === "imageList" ? (
|
|
241
|
+
<ImageListField
|
|
242
|
+
value={Array.isArray(value) ? (value as string[]) : []}
|
|
243
|
+
max={field.max}
|
|
244
|
+
onChange={onChange}
|
|
245
|
+
/>
|
|
246
|
+
) : field.widget === "itemList" ? (
|
|
247
|
+
<ItemListField
|
|
248
|
+
value={
|
|
249
|
+
Array.isArray(value) ? (value as Record<string, string>[]) : []
|
|
250
|
+
}
|
|
251
|
+
itemFields={field.itemFields}
|
|
252
|
+
imageKey={field.imageKey}
|
|
253
|
+
addLabel={field.addLabel}
|
|
254
|
+
onChange={onChange}
|
|
255
|
+
/>
|
|
240
256
|
) : (
|
|
241
257
|
<input
|
|
242
258
|
type={field.widget === "url" ? "url" : "text"}
|
|
@@ -339,3 +355,272 @@ function ImageField({
|
|
|
339
355
|
</div>
|
|
340
356
|
);
|
|
341
357
|
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Upload files through the app-injected capability, falling back to local
|
|
361
|
+
* object URLs when the app didn't wire `uploadImage` (dev/demo). Object URLs
|
|
362
|
+
* are tracked and revoked on unmount so replaced previews don't leak.
|
|
363
|
+
*/
|
|
364
|
+
function useImageUploads() {
|
|
365
|
+
const caps = useContext(EditorCapsContext);
|
|
366
|
+
const [busy, setBusy] = useState(false);
|
|
367
|
+
const blobsRef = useRef<string[]>([]);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const blobs = blobsRef.current;
|
|
370
|
+
return () => {
|
|
371
|
+
for (const b of blobs) URL.revokeObjectURL(b);
|
|
372
|
+
};
|
|
373
|
+
}, []);
|
|
374
|
+
async function upload(files: File[]): Promise<string[]> {
|
|
375
|
+
setBusy(true);
|
|
376
|
+
try {
|
|
377
|
+
const urls: string[] = [];
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
if (caps.uploadImage) {
|
|
380
|
+
urls.push(await caps.uploadImage(file));
|
|
381
|
+
} else {
|
|
382
|
+
const u = URL.createObjectURL(file);
|
|
383
|
+
blobsRef.current.push(u);
|
|
384
|
+
urls.push(u);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return urls;
|
|
388
|
+
} finally {
|
|
389
|
+
setBusy(false);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { upload, busy };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Move `arr[from]` to `to`, returning a new array. */
|
|
396
|
+
function moved<T>(arr: T[], from: number, to: number): T[] {
|
|
397
|
+
const next = [...arr];
|
|
398
|
+
const [x] = next.splice(from, 1);
|
|
399
|
+
next.splice(to, 0, x);
|
|
400
|
+
return next;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `imageList` widget — thumbnails with reorder/remove + multi-file upload. */
|
|
404
|
+
function ImageListField({
|
|
405
|
+
value,
|
|
406
|
+
max,
|
|
407
|
+
onChange,
|
|
408
|
+
}: {
|
|
409
|
+
value: string[];
|
|
410
|
+
max?: number;
|
|
411
|
+
onChange: (val: unknown) => void;
|
|
412
|
+
}) {
|
|
413
|
+
const { upload, busy } = useImageUploads();
|
|
414
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
415
|
+
const [urlDraft, setUrlDraft] = useState("");
|
|
416
|
+
const room = max ? Math.max(0, max - value.length) : Infinity;
|
|
417
|
+
|
|
418
|
+
async function pick(e: ChangeEvent<HTMLInputElement>) {
|
|
419
|
+
const files = Array.from(e.target.files ?? []);
|
|
420
|
+
e.target.value = "";
|
|
421
|
+
if (!files.length) return;
|
|
422
|
+
const urls = await upload(max ? files.slice(0, room) : files);
|
|
423
|
+
if (urls.length) onChange([...value, ...urls]);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div className="space-y-2">
|
|
428
|
+
{value.length > 0 ? (
|
|
429
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
430
|
+
{value.map((url, i) => (
|
|
431
|
+
<div key={`${url}-${i}`} className="group relative">
|
|
432
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
433
|
+
<img src={url} alt="" className="aspect-square w-full rounded-md object-cover" />
|
|
434
|
+
<div className="absolute inset-0 hidden items-end justify-between rounded-md bg-black/40 p-1 group-hover:flex">
|
|
435
|
+
<div className="flex gap-0.5">
|
|
436
|
+
<button
|
|
437
|
+
title="Move left"
|
|
438
|
+
disabled={i === 0}
|
|
439
|
+
onClick={() => onChange(moved(value, i, i - 1))}
|
|
440
|
+
className="rounded bg-white/90 px-1 text-[11px] leading-5 text-black disabled:opacity-40"
|
|
441
|
+
>
|
|
442
|
+
←
|
|
443
|
+
</button>
|
|
444
|
+
<button
|
|
445
|
+
title="Move right"
|
|
446
|
+
disabled={i === value.length - 1}
|
|
447
|
+
onClick={() => onChange(moved(value, i, i + 1))}
|
|
448
|
+
className="rounded bg-white/90 px-1 text-[11px] leading-5 text-black disabled:opacity-40"
|
|
449
|
+
>
|
|
450
|
+
→
|
|
451
|
+
</button>
|
|
452
|
+
</div>
|
|
453
|
+
<button
|
|
454
|
+
title="Remove"
|
|
455
|
+
onClick={() => onChange(value.filter((_, j) => j !== i))}
|
|
456
|
+
className="rounded bg-white/90 px-1 text-[11px] leading-5 text-black"
|
|
457
|
+
>
|
|
458
|
+
×
|
|
459
|
+
</button>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
) : (
|
|
465
|
+
<div className="flex h-16 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70">
|
|
466
|
+
No images yet
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
<button
|
|
470
|
+
onClick={() => ref.current?.click()}
|
|
471
|
+
disabled={busy || room === 0}
|
|
472
|
+
className="w-full rounded-md border px-2 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
|
|
473
|
+
>
|
|
474
|
+
{busy ? "Uploading…" : room === 0 ? `Limit of ${max} reached` : "Add images"}
|
|
475
|
+
</button>
|
|
476
|
+
<input ref={ref} type="file" accept="image/*" multiple hidden onChange={pick} />
|
|
477
|
+
<div className="flex gap-1.5">
|
|
478
|
+
<input
|
|
479
|
+
value={urlDraft}
|
|
480
|
+
onChange={(e) => setUrlDraft(e.target.value)}
|
|
481
|
+
placeholder="or paste image URL"
|
|
482
|
+
className="min-w-0 flex-1 rounded-md border border-border px-2.5 py-1.5 text-xs"
|
|
483
|
+
/>
|
|
484
|
+
<button
|
|
485
|
+
disabled={!urlDraft.trim() || room === 0}
|
|
486
|
+
onClick={() => {
|
|
487
|
+
onChange([...value, urlDraft.trim()]);
|
|
488
|
+
setUrlDraft("");
|
|
489
|
+
}}
|
|
490
|
+
className="rounded-md border px-2 py-1.5 text-xs hover:bg-muted disabled:opacity-50"
|
|
491
|
+
>
|
|
492
|
+
Add
|
|
493
|
+
</button>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** `itemList` widget — structured rows (mini-form each) with optional image chip. */
|
|
500
|
+
function ItemListField({
|
|
501
|
+
value,
|
|
502
|
+
itemFields,
|
|
503
|
+
imageKey,
|
|
504
|
+
addLabel,
|
|
505
|
+
onChange,
|
|
506
|
+
}: {
|
|
507
|
+
value: Record<string, string>[];
|
|
508
|
+
itemFields: { key: string; label: string; placeholder?: string }[];
|
|
509
|
+
imageKey?: string;
|
|
510
|
+
addLabel?: string;
|
|
511
|
+
onChange: (val: unknown) => void;
|
|
512
|
+
}) {
|
|
513
|
+
const { upload, busy } = useImageUploads();
|
|
514
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
515
|
+
// Which row the pending file-pick belongs to.
|
|
516
|
+
const pickRow = useRef<number | null>(null);
|
|
517
|
+
|
|
518
|
+
const setRow = (i: number, patch: Record<string, string>) =>
|
|
519
|
+
onChange(value.map((row, j) => (j === i ? { ...row, ...patch } : row)));
|
|
520
|
+
|
|
521
|
+
async function pick(e: ChangeEvent<HTMLInputElement>) {
|
|
522
|
+
const file = e.target.files?.[0];
|
|
523
|
+
e.target.value = "";
|
|
524
|
+
const i = pickRow.current;
|
|
525
|
+
pickRow.current = null;
|
|
526
|
+
if (!file || i == null || !imageKey) return;
|
|
527
|
+
const [url] = await upload([file]);
|
|
528
|
+
if (url) setRow(i, { [imageKey]: url });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<div className="space-y-2">
|
|
533
|
+
{value.map((row, i) => (
|
|
534
|
+
<div key={i} className="space-y-1.5 rounded-md border border-border p-2">
|
|
535
|
+
<div className="flex items-start gap-2">
|
|
536
|
+
{imageKey && (
|
|
537
|
+
<button
|
|
538
|
+
title={row[imageKey] ? "Replace photo" : "Add photo"}
|
|
539
|
+
disabled={busy}
|
|
540
|
+
onClick={() => {
|
|
541
|
+
pickRow.current = i;
|
|
542
|
+
fileRef.current?.click();
|
|
543
|
+
}}
|
|
544
|
+
className="relative size-10 shrink-0 overflow-hidden rounded-full border border-dashed"
|
|
545
|
+
>
|
|
546
|
+
{row[imageKey] ? (
|
|
547
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
548
|
+
<img src={row[imageKey]} alt="" className="size-full object-cover" />
|
|
549
|
+
) : (
|
|
550
|
+
<span className="text-[10px] text-muted-foreground/70">+</span>
|
|
551
|
+
)}
|
|
552
|
+
</button>
|
|
553
|
+
)}
|
|
554
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
555
|
+
{itemFields.map((f) => (
|
|
556
|
+
<input
|
|
557
|
+
key={f.key}
|
|
558
|
+
defaultValue={row[f.key] ?? ""}
|
|
559
|
+
placeholder={f.placeholder ?? f.label}
|
|
560
|
+
onBlur={(e) => {
|
|
561
|
+
if (e.target.value !== (row[f.key] ?? "")) {
|
|
562
|
+
setRow(i, { [f.key]: e.target.value });
|
|
563
|
+
}
|
|
564
|
+
}}
|
|
565
|
+
className="w-full rounded-md border border-border px-2 py-1 text-xs"
|
|
566
|
+
/>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
<div className="flex shrink-0 flex-col gap-0.5">
|
|
570
|
+
<button
|
|
571
|
+
title="Move up"
|
|
572
|
+
disabled={i === 0}
|
|
573
|
+
onClick={() => onChange(moved(value, i, i - 1))}
|
|
574
|
+
className="rounded border px-1 text-[10px] leading-4 disabled:opacity-40"
|
|
575
|
+
>
|
|
576
|
+
↑
|
|
577
|
+
</button>
|
|
578
|
+
<button
|
|
579
|
+
title="Move down"
|
|
580
|
+
disabled={i === value.length - 1}
|
|
581
|
+
onClick={() => onChange(moved(value, i, i + 1))}
|
|
582
|
+
className="rounded border px-1 text-[10px] leading-4 disabled:opacity-40"
|
|
583
|
+
>
|
|
584
|
+
↓
|
|
585
|
+
</button>
|
|
586
|
+
<button
|
|
587
|
+
title="Remove"
|
|
588
|
+
onClick={() => onChange(value.filter((_, j) => j !== i))}
|
|
589
|
+
className="rounded border px-1 text-[10px] leading-4 text-red-600"
|
|
590
|
+
>
|
|
591
|
+
×
|
|
592
|
+
</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
{imageKey && row[imageKey] && (
|
|
596
|
+
<button
|
|
597
|
+
onClick={() => setRow(i, { [imageKey]: "" })}
|
|
598
|
+
className="text-[10px] text-muted-foreground/70 hover:text-foreground"
|
|
599
|
+
>
|
|
600
|
+
Remove photo
|
|
601
|
+
</button>
|
|
602
|
+
)}
|
|
603
|
+
</div>
|
|
604
|
+
))}
|
|
605
|
+
<button
|
|
606
|
+
onClick={() =>
|
|
607
|
+
onChange([
|
|
608
|
+
...value,
|
|
609
|
+
Object.fromEntries(
|
|
610
|
+
[...itemFields.map((f) => f.key), ...(imageKey ? [imageKey] : [])].map((k) => [
|
|
611
|
+
k,
|
|
612
|
+
"",
|
|
613
|
+
]),
|
|
614
|
+
),
|
|
615
|
+
])
|
|
616
|
+
}
|
|
617
|
+
className="w-full rounded-md border px-2 py-1.5 text-xs hover:bg-muted"
|
|
618
|
+
>
|
|
619
|
+
{addLabel ?? "Add item"}
|
|
620
|
+
</button>
|
|
621
|
+
{imageKey && (
|
|
622
|
+
<input ref={fileRef} type="file" accept="image/*" hidden onChange={pick} />
|
|
623
|
+
)}
|
|
624
|
+
</div>
|
|
625
|
+
);
|
|
626
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
export type ThemeRadius = "none" | "sm" | "md" | "lg";
|
|
12
12
|
export type ThemeSpacing = "tight" | "normal" | "roomy";
|
|
13
|
+
export type ThemeButtonStyle = "filled" | "outline";
|
|
14
|
+
export type ThemeHeadingCase = "none" | "uppercase";
|
|
13
15
|
|
|
14
16
|
/** Responsive breakpoints a section can be hidden on. mobile <640, tablet
|
|
15
17
|
* 640–1024, desktop ≥1024 (Tailwind sm / lg boundaries). */
|
|
@@ -31,6 +33,12 @@ export interface ThemeTokens {
|
|
|
31
33
|
colors: ThemePalette;
|
|
32
34
|
radius: ThemeRadius;
|
|
33
35
|
spacing: ThemeSpacing;
|
|
36
|
+
/** Button treatment across all blocks (v0.5, optional — defaults to "filled"). */
|
|
37
|
+
buttonStyle?: ThemeButtonStyle;
|
|
38
|
+
/** Heading text-transform across all blocks (v0.5, optional — defaults to "none"). */
|
|
39
|
+
headingCase?: ThemeHeadingCase;
|
|
40
|
+
/** Scroll-reveal sections on the published site (v0.5, optional — off by default). */
|
|
41
|
+
reveal?: boolean;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
export interface Section {
|
package/src/registry.ts
CHANGED
|
@@ -55,6 +55,32 @@ export type FieldDescriptor =
|
|
|
55
55
|
altKey?: string;
|
|
56
56
|
placeholder?: string;
|
|
57
57
|
help?: string;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
key: string;
|
|
61
|
+
label: string;
|
|
62
|
+
/** Multi-image manager for `string[]` props — upload several, reorder, remove. */
|
|
63
|
+
widget: "imageList";
|
|
64
|
+
/** Soft cap on image count (layout sanity). */
|
|
65
|
+
max?: number;
|
|
66
|
+
help?: string;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
key: string;
|
|
70
|
+
label: string;
|
|
71
|
+
/**
|
|
72
|
+
* Structured row list for `Array<Record<string, string>>` props — one
|
|
73
|
+
* mini-form per row (add/remove/reorder), e.g. wedding-party members or
|
|
74
|
+
* testimonials. Set `imageKey` to give each row an image chip + upload.
|
|
75
|
+
*/
|
|
76
|
+
widget: "itemList";
|
|
77
|
+
/** Text sub-fields per row, rendered in order. */
|
|
78
|
+
itemFields: { key: string; label: string; placeholder?: string }[];
|
|
79
|
+
/** Row sub-field holding an image URL (upload chip per row). */
|
|
80
|
+
imageKey?: string;
|
|
81
|
+
/** Label for the add-row button, e.g. "Add person". */
|
|
82
|
+
addLabel?: string;
|
|
83
|
+
help?: string;
|
|
58
84
|
};
|
|
59
85
|
|
|
60
86
|
export interface SectionDefinition<
|
package/src/renderer/index.tsx
CHANGED
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Production renderer — RSC-safe.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Production renderer — RSC-safe. The published site uses this: server-renders
|
|
3
|
+
* a `PageDocument` to static HTML, theme tokens on the container, each section
|
|
4
|
+
* via its catalog `Component` (no `edit` prop → pure/static). The editor canvas
|
|
5
|
+
* renders the same components with `edit` injected (see editor/Canvas).
|
|
6
|
+
*
|
|
7
|
+
* The only client import is the `<Reveal>` island, used solely when
|
|
8
|
+
* `doc.theme.reveal === true` (v0.5 scroll-reveal). The editor canvas does NOT
|
|
9
|
+
* go through `renderSection`, so reveals never run while editing.
|
|
7
10
|
*/
|
|
8
|
-
import { createElement, type ReactNode } from "react";
|
|
11
|
+
import { createElement, Fragment, type ReactNode } from "react";
|
|
9
12
|
import type { PageDocument, Section } from "../model";
|
|
10
13
|
import { hideOnClasses } from "../model";
|
|
11
14
|
import { type Catalog, getDefinition, resolveProps } from "../registry";
|
|
12
15
|
import { googleFontsHref, themeVars } from "../theme";
|
|
16
|
+
import { Reveal } from "../blocks/reveal";
|
|
13
17
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* One section, statically. Unknown types render an inert placeholder.
|
|
20
|
+
* `reveal` (PageRenderer-only) wraps the section in the scroll-reveal island.
|
|
21
|
+
*/
|
|
22
|
+
export function renderSection(catalog: Catalog, section: Section, reveal = false): ReactNode {
|
|
16
23
|
const def = getDefinition(catalog, section.type);
|
|
17
24
|
const visibility = hideOnClasses(section.hideOn);
|
|
25
|
+
let node: ReactNode;
|
|
18
26
|
if (!def) {
|
|
19
|
-
|
|
27
|
+
node = createElement(
|
|
20
28
|
"div",
|
|
21
|
-
{
|
|
22
|
-
key: section.id,
|
|
23
|
-
className: `px-6 py-8 text-center text-xs opacity-40 ${visibility}`,
|
|
24
|
-
},
|
|
29
|
+
{ className: `px-6 py-8 text-center text-xs opacity-40 ${visibility}` },
|
|
25
30
|
`Unknown section: ${section.type}`,
|
|
26
31
|
);
|
|
32
|
+
} else {
|
|
33
|
+
const props = resolveProps(def, section.props);
|
|
34
|
+
// Wrap only when the section is hidden on a breakpoint — keeps the DOM flat
|
|
35
|
+
// for normally-visible sections (no wrapper to disturb full-bleed layouts).
|
|
36
|
+
node = visibility
|
|
37
|
+
? createElement("div", { className: visibility }, createElement(def.Component, props))
|
|
38
|
+
: createElement(def.Component, props);
|
|
27
39
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// for normally-visible sections (no wrapper to disturb full-bleed layouts).
|
|
31
|
-
if (!visibility) return createElement(def.Component, { key: section.id, ...props });
|
|
32
|
-
const el = createElement(def.Component, props);
|
|
33
|
-
return createElement("div", { key: section.id, className: visibility }, el);
|
|
40
|
+
if (reveal) node = createElement(Reveal, null, node);
|
|
41
|
+
return createElement(Fragment, { key: section.id }, node);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
export function PageRenderer({
|
|
@@ -42,9 +50,12 @@ export function PageRenderer({
|
|
|
42
50
|
catalog: Catalog;
|
|
43
51
|
className?: string;
|
|
44
52
|
}) {
|
|
53
|
+
const reveal = doc.theme.reveal === true;
|
|
45
54
|
return (
|
|
46
55
|
<div style={themeVars(doc.theme)} className={className}>
|
|
47
|
-
{
|
|
56
|
+
{/* Section 0 is never reveal-wrapped: it's above the fold (static anyway)
|
|
57
|
+
and a wrapper div would defeat a sticky/transparent NavBlock there. */}
|
|
58
|
+
{doc.sections.map((section, i) => renderSection(catalog, section, reveal && i > 0))}
|
|
48
59
|
</div>
|
|
49
60
|
);
|
|
50
61
|
}
|