@jimmy_harika/websitekit 0.4.0 → 0.5.1
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 +24 -1
- package/package.json +1 -1
- 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 +106 -22
- package/src/editor/edit-primitives.tsx +57 -8
- package/src/model.ts +8 -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
|
@@ -292,11 +292,12 @@ function ImageField({
|
|
|
292
292
|
if (blobRef.current) URL.revokeObjectURL(blobRef.current);
|
|
293
293
|
};
|
|
294
294
|
}, []);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
295
|
+
const [err, setErr] = useState<string | null>(null);
|
|
296
|
+
const [dragOver, setDragOver] = useState(false);
|
|
297
|
+
|
|
298
|
+
async function uploadFile(file: File) {
|
|
299
299
|
setBusy(true);
|
|
300
|
+
setErr(null);
|
|
300
301
|
try {
|
|
301
302
|
const usedFallback = !caps.uploadImage;
|
|
302
303
|
const url = caps.uploadImage
|
|
@@ -307,18 +308,57 @@ function ImageField({
|
|
|
307
308
|
blobRef.current = url;
|
|
308
309
|
}
|
|
309
310
|
onChange(url);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
setErr(e instanceof Error ? e.message : "Upload failed");
|
|
310
313
|
} finally {
|
|
311
314
|
setBusy(false);
|
|
312
315
|
}
|
|
313
316
|
}
|
|
317
|
+
|
|
318
|
+
async function pick(e: ChangeEvent<HTMLInputElement>) {
|
|
319
|
+
const file = e.target.files?.[0];
|
|
320
|
+
e.target.value = "";
|
|
321
|
+
if (!file) return;
|
|
322
|
+
await uploadFile(file);
|
|
323
|
+
}
|
|
314
324
|
return (
|
|
315
325
|
<div className="space-y-2">
|
|
316
326
|
{value ? (
|
|
317
327
|
// eslint-disable-next-line @next/next/no-img-element
|
|
318
|
-
<img
|
|
328
|
+
<img
|
|
329
|
+
src={value}
|
|
330
|
+
alt={altValue ?? ""}
|
|
331
|
+
className={`h-24 w-full rounded-md object-cover ${dragOver ? "ring-2 ring-sky-400" : ""}`}
|
|
332
|
+
onDragOver={(e) => {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
setDragOver(true);
|
|
335
|
+
}}
|
|
336
|
+
onDragLeave={() => setDragOver(false)}
|
|
337
|
+
onDrop={(e) => {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
setDragOver(false);
|
|
340
|
+
const f = e.dataTransfer.files?.[0];
|
|
341
|
+
if (f) void uploadFile(f);
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
319
344
|
) : (
|
|
320
|
-
<div
|
|
321
|
-
|
|
345
|
+
<div
|
|
346
|
+
className={`flex h-24 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70 ${
|
|
347
|
+
dragOver ? "border-sky-400 bg-sky-400/10" : ""
|
|
348
|
+
}`}
|
|
349
|
+
onDragOver={(e) => {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
setDragOver(true);
|
|
352
|
+
}}
|
|
353
|
+
onDragLeave={() => setDragOver(false)}
|
|
354
|
+
onDrop={(e) => {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
setDragOver(false);
|
|
357
|
+
const f = e.dataTransfer.files?.[0];
|
|
358
|
+
if (f) void uploadFile(f);
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
{busy ? "Uploading…" : "No image — click or drop"}
|
|
322
362
|
</div>
|
|
323
363
|
)}
|
|
324
364
|
<div className="flex gap-2">
|
|
@@ -337,7 +377,8 @@ function ImageField({
|
|
|
337
377
|
</button>
|
|
338
378
|
)}
|
|
339
379
|
</div>
|
|
340
|
-
|
|
380
|
+
{err && <p className="text-[10px] text-red-600 dark:text-red-400">{err}</p>}
|
|
381
|
+
<input ref={ref} type="file" accept="image/*,.heic,.heif,image/heic,image/heif" hidden onChange={pick} />
|
|
341
382
|
<input
|
|
342
383
|
value={value}
|
|
343
384
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -376,12 +417,16 @@ function useImageUploads() {
|
|
|
376
417
|
try {
|
|
377
418
|
const urls: string[] = [];
|
|
378
419
|
for (const file of files) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
420
|
+
try {
|
|
421
|
+
if (caps.uploadImage) {
|
|
422
|
+
urls.push(await caps.uploadImage(file));
|
|
423
|
+
} else {
|
|
424
|
+
const u = URL.createObjectURL(file);
|
|
425
|
+
blobsRef.current.push(u);
|
|
426
|
+
urls.push(u);
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
// Skip failed files; caller can show partial results
|
|
385
430
|
}
|
|
386
431
|
}
|
|
387
432
|
return urls;
|
|
@@ -415,14 +460,20 @@ function ImageListField({
|
|
|
415
460
|
const [urlDraft, setUrlDraft] = useState("");
|
|
416
461
|
const room = max ? Math.max(0, max - value.length) : Infinity;
|
|
417
462
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
463
|
+
const [dragOver, setDragOver] = useState(false);
|
|
464
|
+
|
|
465
|
+
async function addFiles(files: File[]) {
|
|
421
466
|
if (!files.length) return;
|
|
422
467
|
const urls = await upload(max ? files.slice(0, room) : files);
|
|
423
468
|
if (urls.length) onChange([...value, ...urls]);
|
|
424
469
|
}
|
|
425
470
|
|
|
471
|
+
async function pick(e: ChangeEvent<HTMLInputElement>) {
|
|
472
|
+
const files = Array.from(e.target.files ?? []);
|
|
473
|
+
e.target.value = "";
|
|
474
|
+
await addFiles(files);
|
|
475
|
+
}
|
|
476
|
+
|
|
426
477
|
return (
|
|
427
478
|
<div className="space-y-2">
|
|
428
479
|
{value.length > 0 ? (
|
|
@@ -462,18 +513,51 @@ function ImageListField({
|
|
|
462
513
|
))}
|
|
463
514
|
</div>
|
|
464
515
|
) : (
|
|
465
|
-
<div
|
|
466
|
-
|
|
516
|
+
<div
|
|
517
|
+
className={`flex h-16 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70 ${
|
|
518
|
+
dragOver ? "border-sky-400 bg-sky-400/10" : ""
|
|
519
|
+
}`}
|
|
520
|
+
onDragOver={(e) => {
|
|
521
|
+
e.preventDefault();
|
|
522
|
+
setDragOver(true);
|
|
523
|
+
}}
|
|
524
|
+
onDragLeave={() => setDragOver(false)}
|
|
525
|
+
onDrop={(e) => {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
setDragOver(false);
|
|
528
|
+
void addFiles(Array.from(e.dataTransfer.files ?? []));
|
|
529
|
+
}}
|
|
530
|
+
>
|
|
531
|
+
No images yet — drop here
|
|
467
532
|
</div>
|
|
468
533
|
)}
|
|
469
534
|
<button
|
|
470
535
|
onClick={() => ref.current?.click()}
|
|
471
536
|
disabled={busy || room === 0}
|
|
472
|
-
className=
|
|
537
|
+
className={`w-full rounded-md border px-2 py-1.5 text-xs hover:bg-muted disabled:opacity-50 ${
|
|
538
|
+
dragOver ? "border-sky-400" : ""
|
|
539
|
+
}`}
|
|
540
|
+
onDragOver={(e) => {
|
|
541
|
+
e.preventDefault();
|
|
542
|
+
setDragOver(true);
|
|
543
|
+
}}
|
|
544
|
+
onDragLeave={() => setDragOver(false)}
|
|
545
|
+
onDrop={(e) => {
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
setDragOver(false);
|
|
548
|
+
void addFiles(Array.from(e.dataTransfer.files ?? []));
|
|
549
|
+
}}
|
|
473
550
|
>
|
|
474
|
-
{busy ? "Uploading…" : room === 0 ? `Limit of ${max} reached` : "Add images"}
|
|
551
|
+
{busy ? "Uploading…" : room === 0 ? `Limit of ${max} reached` : "Add images (click or drop)"}
|
|
475
552
|
</button>
|
|
476
|
-
<input
|
|
553
|
+
<input
|
|
554
|
+
ref={ref}
|
|
555
|
+
type="file"
|
|
556
|
+
accept="image/*,.heic,.heif,image/heic,image/heif"
|
|
557
|
+
multiple
|
|
558
|
+
hidden
|
|
559
|
+
onChange={pick}
|
|
560
|
+
/>
|
|
477
561
|
<div className="flex gap-1.5">
|
|
478
562
|
<input
|
|
479
563
|
value={urlDraft}
|
|
@@ -86,12 +86,17 @@ function EditableText({
|
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
const IMAGE_ACCEPT =
|
|
90
|
+
"image/*,.heic,.heif,image/heic,image/heif";
|
|
91
|
+
|
|
89
92
|
function EditableImage({ field, src, alt, className, style, width, height, priority }: ImageEditProps) {
|
|
90
93
|
const sectionId = useContext(EditSectionContext);
|
|
91
94
|
const updateProps = useBuilder((s) => s.updateProps);
|
|
92
95
|
const caps = useContext(EditorCapsContext);
|
|
93
96
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
94
97
|
const [busy, setBusy] = useState(false);
|
|
98
|
+
const [err, setErr] = useState<string | null>(null);
|
|
99
|
+
const [dragOver, setDragOver] = useState(false);
|
|
95
100
|
// Track object URLs we minted so we can revoke them (avoid leaks on replace/unmount).
|
|
96
101
|
const blobRef = useRef<string | null>(null);
|
|
97
102
|
useEffect(() => {
|
|
@@ -100,11 +105,10 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
100
105
|
};
|
|
101
106
|
}, []);
|
|
102
107
|
|
|
103
|
-
async function
|
|
104
|
-
|
|
105
|
-
e.target.value = "";
|
|
106
|
-
if (!file || !sectionId) return;
|
|
108
|
+
async function uploadFile(file: File) {
|
|
109
|
+
if (!sectionId) return;
|
|
107
110
|
setBusy(true);
|
|
111
|
+
setErr(null);
|
|
108
112
|
try {
|
|
109
113
|
const usedFallback = !caps.uploadImage;
|
|
110
114
|
const url = caps.uploadImage
|
|
@@ -115,16 +119,49 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
115
119
|
blobRef.current = url;
|
|
116
120
|
}
|
|
117
121
|
updateProps(sectionId, { [field]: url });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
setErr(e instanceof Error ? e.message : "Upload failed");
|
|
118
124
|
} finally {
|
|
119
125
|
setBusy(false);
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
|
130
|
+
const file = e.target.files?.[0];
|
|
131
|
+
e.target.value = "";
|
|
132
|
+
if (!file) return;
|
|
133
|
+
await uploadFile(file);
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
const open = (e: ReactMouseEvent) => {
|
|
124
137
|
e.stopPropagation();
|
|
125
138
|
inputRef.current?.click();
|
|
126
139
|
};
|
|
127
140
|
|
|
141
|
+
const onDragOver = (e: React.DragEvent) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
setDragOver(true);
|
|
145
|
+
};
|
|
146
|
+
const onDragLeave = (e: React.DragEvent) => {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
setDragOver(false);
|
|
150
|
+
};
|
|
151
|
+
const onDrop = (e: React.DragEvent) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
setDragOver(false);
|
|
155
|
+
const file = e.dataTransfer.files?.[0];
|
|
156
|
+
if (file) void uploadFile(file);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const dropProps = {
|
|
160
|
+
onDragOver,
|
|
161
|
+
onDragLeave,
|
|
162
|
+
onDrop,
|
|
163
|
+
};
|
|
164
|
+
|
|
128
165
|
return (
|
|
129
166
|
<>
|
|
130
167
|
{src ? (
|
|
@@ -136,24 +173,36 @@ function EditableImage({ field, src, alt, className, style, width, height, prior
|
|
|
136
173
|
height={height}
|
|
137
174
|
loading={priority ? "eager" : "lazy"}
|
|
138
175
|
fetchPriority={priority ? "high" : "auto"}
|
|
139
|
-
className={cn(
|
|
176
|
+
className={cn(
|
|
177
|
+
"cursor-pointer outline-2 outline-offset-2 outline-transparent hover:outline-sky-400",
|
|
178
|
+
dragOver && "outline-sky-400",
|
|
179
|
+
className,
|
|
180
|
+
)}
|
|
140
181
|
style={style}
|
|
141
|
-
title={busy ? "Uploading…" : "Click to replace"}
|
|
182
|
+
title={busy ? "Uploading…" : err ?? "Click or drop to replace"}
|
|
142
183
|
onClick={open}
|
|
184
|
+
{...dropProps}
|
|
143
185
|
/>
|
|
144
186
|
) : (
|
|
145
187
|
<div
|
|
146
188
|
className={cn(
|
|
147
189
|
"flex cursor-pointer items-center justify-center bg-black/[0.04] text-[11px] uppercase tracking-wider opacity-50 outline-2 outline-dashed outline-black/15 hover:outline-sky-400",
|
|
190
|
+
dragOver && "outline-sky-400 opacity-80",
|
|
148
191
|
className,
|
|
149
192
|
)}
|
|
150
193
|
style={style}
|
|
151
194
|
onClick={open}
|
|
195
|
+
{...dropProps}
|
|
152
196
|
>
|
|
153
|
-
{busy ? "Uploading…" : "Add image"}
|
|
197
|
+
{busy ? "Uploading…" : err ? "Upload failed — try again" : "Add image (click or drop)"}
|
|
154
198
|
</div>
|
|
155
199
|
)}
|
|
156
|
-
|
|
200
|
+
{err && (
|
|
201
|
+
<p className="mt-1 text-[10px] text-red-600 dark:text-red-400" title={err}>
|
|
202
|
+
{err}
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
<input ref={inputRef} type="file" accept={IMAGE_ACCEPT} hidden onChange={onPick} />
|
|
157
206
|
</>
|
|
158
207
|
);
|
|
159
208
|
}
|
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/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
|
}
|