@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
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ perf moat heavy runtimes can't match.
|
|
|
26
26
|
| `@jimmy_harika/websitekit/renderer` | `PageRenderer` / `PageFonts` (RSC-safe production render) |
|
|
27
27
|
| `@jimmy_harika/websitekit/store` | zustand + zundo store |
|
|
28
28
|
| `@jimmy_harika/websitekit/blocks` | Design-system-agnostic blocks — render off `--pb-*` theme tokens, no app-UI imports (this is what makes a block portable to every app) |
|
|
29
|
+
| `@jimmy_harika/websitekit/blocks` (v0.5 additions) | Site-system blocks for photographer-caliber templates — `NavBlock`, `HeroSlideshowBlock`, `BannerQuoteBlock`, `LogoBandBlock`, `TeaserCardsBlock`, `CategoryShowcaseBlock`, `TestimonialSliderBlock`, `SiteFooterBlock`, plus the `Reveal` scroll-reveal + shared `Slider` islands and theme `buttonStyle`/`headingCase`/`reveal` |
|
|
29
30
|
| `@jimmy_harika/websitekit/catalogs/author` | Author-website catalog + templates |
|
|
30
31
|
|
|
31
32
|
## The app contract
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jimmy_harika/websitekit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app — engine + design-system-agnostic blocks + per-industry catalogs (author, photographer, …). Each app plugs in a catalog + theme + persistence adapter.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BackToTop — micro client island for SiteFooterBlock. Smooth-scrolls to the
|
|
5
|
+
* top (instant under prefers-reduced-motion).
|
|
6
|
+
*/
|
|
7
|
+
import { BODY, MUTED } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export function BackToTop() {
|
|
10
|
+
return (
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={() => {
|
|
14
|
+
const reduced =
|
|
15
|
+
typeof window.matchMedia === "function" &&
|
|
16
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
17
|
+
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
|
|
18
|
+
}}
|
|
19
|
+
className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
|
|
20
|
+
style={{ fontFamily: BODY, color: MUTED }}
|
|
21
|
+
>
|
|
22
|
+
Back to top ↑
|
|
23
|
+
</button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BannerQuoteBlock — a single statement quote as a full-width band. With
|
|
3
|
+
* `image` it renders over the photo behind a scrim; without, a flat tinted
|
|
4
|
+
* band in theme tokens. Server-safe, edit-aware.
|
|
5
|
+
*/
|
|
6
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
7
|
+
import { ACCENT, BODY, HEADING, MUTED, Section, TEXT, TINT } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export interface BannerQuoteBlockProps {
|
|
10
|
+
quote: string;
|
|
11
|
+
attribution?: string;
|
|
12
|
+
/** Background photo — rendered behind a dark scrim with light text. */
|
|
13
|
+
image?: string;
|
|
14
|
+
align?: "center" | "split";
|
|
15
|
+
edit?: EditPrimitives;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function BannerQuoteBlock({
|
|
19
|
+
quote,
|
|
20
|
+
attribution,
|
|
21
|
+
image,
|
|
22
|
+
align = "center",
|
|
23
|
+
edit,
|
|
24
|
+
}: BannerQuoteBlockProps) {
|
|
25
|
+
const onPhoto = Boolean(image);
|
|
26
|
+
const quoteColor = onPhoto ? "#ffffff" : TEXT;
|
|
27
|
+
const metaColor = onPhoto ? "rgba(255,255,255,0.75)" : MUTED;
|
|
28
|
+
|
|
29
|
+
const quoteEl = renderText(edit, {
|
|
30
|
+
field: "quote",
|
|
31
|
+
value: quote,
|
|
32
|
+
as: "blockquote",
|
|
33
|
+
placeholder: "A line you want remembered.",
|
|
34
|
+
multiline: true,
|
|
35
|
+
className:
|
|
36
|
+
align === "split"
|
|
37
|
+
? "text-[clamp(1.4rem,3.2vw,2.25rem)] font-light italic leading-snug"
|
|
38
|
+
: "mx-auto max-w-3xl text-center text-[clamp(1.4rem,3.5vw,2.5rem)] font-light italic leading-snug",
|
|
39
|
+
style: { fontFamily: HEADING, color: quoteColor },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const attributionEl =
|
|
43
|
+
attribution || edit
|
|
44
|
+
? renderText(edit, {
|
|
45
|
+
field: "attribution",
|
|
46
|
+
value: attribution ?? "",
|
|
47
|
+
as: "p",
|
|
48
|
+
placeholder: "Someone kind",
|
|
49
|
+
className:
|
|
50
|
+
align === "split"
|
|
51
|
+
? "text-[11px] uppercase tracking-[0.3em]"
|
|
52
|
+
: "mt-6 text-center text-[11px] uppercase tracking-[0.3em]",
|
|
53
|
+
style: { fontFamily: BODY, color: onPhoto ? metaColor : ACCENT },
|
|
54
|
+
})
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
const body =
|
|
58
|
+
align === "split" ? (
|
|
59
|
+
<div className="mx-auto grid max-w-5xl items-end gap-8 md:grid-cols-[1fr_auto]">
|
|
60
|
+
<div>{quoteEl}</div>
|
|
61
|
+
<div className="md:pb-2">{attributionEl}</div>
|
|
62
|
+
</div>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="mx-auto max-w-4xl">
|
|
65
|
+
{quoteEl}
|
|
66
|
+
{attributionEl}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!onPhoto) {
|
|
71
|
+
return <Section style={{ background: TINT }}>{body}</Section>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className="relative w-full overflow-hidden">
|
|
76
|
+
<div className="absolute inset-0">
|
|
77
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
78
|
+
<img src={image} alt="" loading="lazy" className="h-full w-full object-cover" />
|
|
79
|
+
</div>
|
|
80
|
+
<div className="absolute inset-0" style={{ background: "rgba(0,0,0,0.5)" }} />
|
|
81
|
+
<div
|
|
82
|
+
className="relative px-6 sm:px-10"
|
|
83
|
+
style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 7rem)" }}
|
|
84
|
+
>
|
|
85
|
+
{body}
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CategoryShowcaseBlock — editorial category columns (e.g. Weddings /
|
|
3
|
+
* Portraits / Editorial) with staggered heights and small-caps captions.
|
|
4
|
+
* Even columns drop down on desktop for the staggered-editorial look.
|
|
5
|
+
* Server-safe, edit-aware heading.
|
|
6
|
+
*/
|
|
7
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
8
|
+
import {
|
|
9
|
+
BODY,
|
|
10
|
+
HEADING,
|
|
11
|
+
HEADING_CASE,
|
|
12
|
+
MUTED,
|
|
13
|
+
RADIUS,
|
|
14
|
+
Section,
|
|
15
|
+
TEXT,
|
|
16
|
+
TokenButton,
|
|
17
|
+
} from "./primitives";
|
|
18
|
+
|
|
19
|
+
export interface CategoryShowcaseItem {
|
|
20
|
+
title: string;
|
|
21
|
+
image: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CategoryShowcaseBlockProps {
|
|
26
|
+
heading?: string;
|
|
27
|
+
items?: CategoryShowcaseItem[];
|
|
28
|
+
ctaLabel?: string;
|
|
29
|
+
ctaHref?: string;
|
|
30
|
+
edit?: EditPrimitives;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Literal class strings so Tailwind's scanner picks them up. */
|
|
34
|
+
const GRID_COLS: Record<number, string> = {
|
|
35
|
+
2: "sm:grid-cols-2",
|
|
36
|
+
3: "sm:grid-cols-2 md:grid-cols-3",
|
|
37
|
+
4: "sm:grid-cols-2 md:grid-cols-4",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function CategoryShowcaseBlock({
|
|
41
|
+
heading,
|
|
42
|
+
items = [],
|
|
43
|
+
ctaLabel,
|
|
44
|
+
ctaHref,
|
|
45
|
+
edit,
|
|
46
|
+
}: CategoryShowcaseBlockProps) {
|
|
47
|
+
const cols = Math.min(Math.max(items.length, 2), 4);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Section>
|
|
51
|
+
{(heading || edit) &&
|
|
52
|
+
renderText(edit, {
|
|
53
|
+
field: "heading",
|
|
54
|
+
value: heading ?? "",
|
|
55
|
+
as: "h2",
|
|
56
|
+
placeholder: "Explore the work",
|
|
57
|
+
className: "mb-12 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
|
|
58
|
+
style: { fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE },
|
|
59
|
+
})}
|
|
60
|
+
{items.length === 0 ? (
|
|
61
|
+
<p className="py-8 text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
|
|
62
|
+
Add categories (title + image).
|
|
63
|
+
</p>
|
|
64
|
+
) : (
|
|
65
|
+
<div className={`grid gap-6 md:gap-8 ${GRID_COLS[cols]}`}>
|
|
66
|
+
{items.map((item, i) => {
|
|
67
|
+
const staggered = i % 2 === 1;
|
|
68
|
+
const figure = (
|
|
69
|
+
<figure className={staggered ? "md:mt-14" : undefined}>
|
|
70
|
+
<div
|
|
71
|
+
className={`w-full overflow-hidden ${staggered ? "aspect-[3/4]" : "aspect-[7/10]"}`}
|
|
72
|
+
style={{ borderRadius: RADIUS }}
|
|
73
|
+
>
|
|
74
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
75
|
+
<img
|
|
76
|
+
src={item.image}
|
|
77
|
+
alt={item.title}
|
|
78
|
+
loading="lazy"
|
|
79
|
+
className="h-full w-full object-cover transition duration-500 hover:scale-[1.03]"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<figcaption
|
|
83
|
+
className="mt-4 text-center text-[11px] uppercase tracking-[0.3em]"
|
|
84
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
85
|
+
>
|
|
86
|
+
{item.title}
|
|
87
|
+
</figcaption>
|
|
88
|
+
</figure>
|
|
89
|
+
);
|
|
90
|
+
return item.href ? (
|
|
91
|
+
<a key={i} href={item.href} className="block">
|
|
92
|
+
{figure}
|
|
93
|
+
</a>
|
|
94
|
+
) : (
|
|
95
|
+
<div key={i}>{figure}</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{ctaLabel && ctaHref && (
|
|
101
|
+
<div className="mt-12 text-center">
|
|
102
|
+
<TokenButton href={ctaHref}>{ctaLabel}</TokenButton>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</Section>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeroSlideshowBlock — full-bleed crossfade hero ("fade") or center-dominant
|
|
3
|
+
* filmstrip with peeking neighbors ("filmstrip"). Server-safe: the images and
|
|
4
|
+
* copy render here; index state lives in the shared <Slider> island.
|
|
5
|
+
*/
|
|
6
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
7
|
+
import { BODY, HEADING, HEADING_CASE, MUTED, RADIUS, TEXT } from "./primitives";
|
|
8
|
+
import { Slider } from "./slider";
|
|
9
|
+
|
|
10
|
+
export interface HeroSlideshowBlockProps {
|
|
11
|
+
images: string[];
|
|
12
|
+
headline?: string;
|
|
13
|
+
sub?: string;
|
|
14
|
+
align?: "center" | "left" | "bottom-left";
|
|
15
|
+
height?: "full" | "tall";
|
|
16
|
+
variant?: "fade" | "filmstrip";
|
|
17
|
+
/** Auto-advance every ~5s (off by default; disabled under reduced motion). */
|
|
18
|
+
autoplay?: boolean;
|
|
19
|
+
edit?: EditPrimitives;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ALIGN: Record<NonNullable<HeroSlideshowBlockProps["align"]>, string> = {
|
|
23
|
+
center: "items-center justify-center text-center",
|
|
24
|
+
left: "items-center justify-start text-left",
|
|
25
|
+
"bottom-left": "items-end justify-start pb-16 text-left",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function HeroSlideshowBlock({
|
|
29
|
+
images,
|
|
30
|
+
headline,
|
|
31
|
+
sub,
|
|
32
|
+
align = "center",
|
|
33
|
+
height = "full",
|
|
34
|
+
variant = "fade",
|
|
35
|
+
autoplay = false,
|
|
36
|
+
edit,
|
|
37
|
+
}: HeroSlideshowBlockProps) {
|
|
38
|
+
const shots = images ?? [];
|
|
39
|
+
const autoplayMs = autoplay ? 5000 : 0;
|
|
40
|
+
|
|
41
|
+
if (shots.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<section className="flex min-h-[40vh] items-center justify-center px-6">
|
|
44
|
+
<p className="text-sm" style={{ color: MUTED, fontFamily: BODY }}>
|
|
45
|
+
Add slideshow images.
|
|
46
|
+
</p>
|
|
47
|
+
</section>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (variant === "filmstrip") {
|
|
52
|
+
return (
|
|
53
|
+
<section
|
|
54
|
+
className="w-full overflow-hidden"
|
|
55
|
+
style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 4rem)" }}
|
|
56
|
+
>
|
|
57
|
+
{(headline || edit) && (
|
|
58
|
+
<div className="mx-auto mb-10 max-w-3xl px-6 text-center">
|
|
59
|
+
{renderText(edit, {
|
|
60
|
+
field: "headline",
|
|
61
|
+
value: headline ?? "",
|
|
62
|
+
as: "h1",
|
|
63
|
+
placeholder: "A headline for your work",
|
|
64
|
+
className: "text-[clamp(2rem,5vw,3.5rem)] font-light leading-tight",
|
|
65
|
+
style: { fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE },
|
|
66
|
+
})}
|
|
67
|
+
{(sub || edit) &&
|
|
68
|
+
renderText(edit, {
|
|
69
|
+
field: "sub",
|
|
70
|
+
value: sub ?? "",
|
|
71
|
+
as: "p",
|
|
72
|
+
placeholder: "A short supporting line.",
|
|
73
|
+
className: "mt-4 text-base leading-relaxed",
|
|
74
|
+
style: { fontFamily: BODY, color: MUTED },
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
<Slider
|
|
79
|
+
variant="track"
|
|
80
|
+
arrows="below"
|
|
81
|
+
autoplayMs={autoplayMs}
|
|
82
|
+
slideWidth="min(72vw, 880px)"
|
|
83
|
+
label={headline || "Featured work"}
|
|
84
|
+
slides={shots.map((src, i) => (
|
|
85
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
86
|
+
<img
|
|
87
|
+
key={i}
|
|
88
|
+
src={src}
|
|
89
|
+
alt=""
|
|
90
|
+
loading={i === 0 ? "eager" : "lazy"}
|
|
91
|
+
className="aspect-[3/2] w-full object-cover"
|
|
92
|
+
style={{ borderRadius: RADIUS }}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
/>
|
|
96
|
+
</section>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// fade — overlay copy defines the container height.
|
|
101
|
+
const minH = height === "tall" ? "min-h-[70vh]" : "min-h-[100svh]";
|
|
102
|
+
return (
|
|
103
|
+
<Slider
|
|
104
|
+
variant="fade"
|
|
105
|
+
arrows="overlay"
|
|
106
|
+
autoplayMs={autoplayMs}
|
|
107
|
+
label={headline || "Slideshow"}
|
|
108
|
+
slides={shots.map((src, i) => (
|
|
109
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
110
|
+
<img
|
|
111
|
+
key={i}
|
|
112
|
+
src={src}
|
|
113
|
+
alt=""
|
|
114
|
+
loading={i === 0 ? "eager" : "lazy"}
|
|
115
|
+
fetchPriority={i === 0 ? "high" : "auto"}
|
|
116
|
+
className="h-full w-full object-cover"
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
overlay={
|
|
120
|
+
<>
|
|
121
|
+
<div
|
|
122
|
+
className="absolute inset-0"
|
|
123
|
+
style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.45))" }}
|
|
124
|
+
/>
|
|
125
|
+
<div className={`relative flex ${minH} w-full px-6 sm:px-14 ${ALIGN[align]}`}>
|
|
126
|
+
<div className="max-w-3xl">
|
|
127
|
+
{(headline || edit) &&
|
|
128
|
+
renderText(edit, {
|
|
129
|
+
field: "headline",
|
|
130
|
+
value: headline ?? "",
|
|
131
|
+
as: "h1",
|
|
132
|
+
placeholder: "A headline for your work",
|
|
133
|
+
className: "text-[clamp(2.25rem,7vw,5rem)] font-light leading-[1.02] text-white",
|
|
134
|
+
style: { fontFamily: HEADING, textTransform: HEADING_CASE },
|
|
135
|
+
})}
|
|
136
|
+
{(sub || edit) &&
|
|
137
|
+
renderText(edit, {
|
|
138
|
+
field: "sub",
|
|
139
|
+
value: sub ?? "",
|
|
140
|
+
as: "p",
|
|
141
|
+
placeholder: "A short supporting line.",
|
|
142
|
+
className: "mt-5 max-w-xl text-[13px] uppercase tracking-[0.3em] text-white/80",
|
|
143
|
+
style: { fontFamily: BODY },
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</>
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
package/src/blocks/index.ts
CHANGED
|
@@ -10,15 +10,26 @@ export {
|
|
|
10
10
|
ACCENT,
|
|
11
11
|
BG,
|
|
12
12
|
BODY,
|
|
13
|
+
BTN_BG,
|
|
14
|
+
BTN_BORDER,
|
|
15
|
+
BTN_FG,
|
|
16
|
+
HAIRLINE,
|
|
13
17
|
HEADING,
|
|
18
|
+
HEADING_CASE,
|
|
14
19
|
MUTED,
|
|
15
20
|
RADIUS,
|
|
16
21
|
TEXT,
|
|
22
|
+
TINT,
|
|
17
23
|
Section,
|
|
18
24
|
Eyebrow,
|
|
19
25
|
AccentLink,
|
|
26
|
+
TokenButton,
|
|
20
27
|
} from "./primitives";
|
|
21
28
|
|
|
29
|
+
// v0.5 system layer — reveal + shared slider island
|
|
30
|
+
export { Reveal, type RevealProps } from "./reveal";
|
|
31
|
+
export { Slider, type SliderProps } from "./slider";
|
|
32
|
+
|
|
22
33
|
export { AuthorHero, type AuthorHeroProps } from "./author-hero";
|
|
23
34
|
export { AuthorBio, type AuthorBioProps } from "./author-bio";
|
|
24
35
|
export { BookSpotlight, type BookSpotlightProps } from "./book-spotlight";
|
|
@@ -30,3 +41,32 @@ export { Form, type FormProps } from "./form";
|
|
|
30
41
|
export { Pricing, type PricingProps } from "./pricing";
|
|
31
42
|
export { Testimonials, type TestimonialsProps } from "./testimonials";
|
|
32
43
|
export { Faq, type FaqProps } from "./faq";
|
|
44
|
+
|
|
45
|
+
// v0.5 blocks — the Pixieset-caliber photographer-template layer
|
|
46
|
+
export { NavBlock, type NavBlockProps, type NavLinkItem } from "./nav";
|
|
47
|
+
export { NavMenu } from "./nav-menu";
|
|
48
|
+
export {
|
|
49
|
+
HeroSlideshowBlock,
|
|
50
|
+
type HeroSlideshowBlockProps,
|
|
51
|
+
} from "./hero-slideshow";
|
|
52
|
+
export { BannerQuoteBlock, type BannerQuoteBlockProps } from "./banner-quote";
|
|
53
|
+
export { LogoBandBlock, type LogoBandBlockProps, type LogoBandItem } from "./logo-band";
|
|
54
|
+
export { TeaserCardsBlock, type TeaserCardsBlockProps, type TeaserCardItem } from "./teaser-cards";
|
|
55
|
+
export {
|
|
56
|
+
CategoryShowcaseBlock,
|
|
57
|
+
type CategoryShowcaseBlockProps,
|
|
58
|
+
type CategoryShowcaseItem,
|
|
59
|
+
} from "./category-showcase";
|
|
60
|
+
export {
|
|
61
|
+
TestimonialSliderBlock,
|
|
62
|
+
type TestimonialSliderBlockProps,
|
|
63
|
+
type TestimonialSlideItem,
|
|
64
|
+
} from "./testimonial-slider";
|
|
65
|
+
export {
|
|
66
|
+
SiteFooterBlock,
|
|
67
|
+
type SiteFooterBlockProps,
|
|
68
|
+
type SiteFooterNavLink,
|
|
69
|
+
type SiteFooterSocial,
|
|
70
|
+
type SiteFooterSocialKind,
|
|
71
|
+
} from "./site-footer";
|
|
72
|
+
export { BackToTop } from "./back-to-top";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogoBandBlock — press / "as seen in" strip on a tinted band. Items render as
|
|
3
|
+
* grayscale image logos when `image` is set, otherwise as letterspaced
|
|
4
|
+
* small-caps text names. Server-safe, edit-aware heading.
|
|
5
|
+
*/
|
|
6
|
+
import { renderText, type EditPrimitives } from "../editable";
|
|
7
|
+
import { ACCENT, BODY, MUTED, Section } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export interface LogoBandItem {
|
|
10
|
+
name: string;
|
|
11
|
+
image?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LogoBandBlockProps {
|
|
15
|
+
heading?: string;
|
|
16
|
+
items?: LogoBandItem[];
|
|
17
|
+
edit?: EditPrimitives;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LogoBandBlock({ heading, items = [], edit }: LogoBandBlockProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Section tint style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 3rem)" }}>
|
|
23
|
+
{(heading || edit) &&
|
|
24
|
+
renderText(edit, {
|
|
25
|
+
field: "heading",
|
|
26
|
+
value: heading ?? "",
|
|
27
|
+
as: "p",
|
|
28
|
+
placeholder: "As seen in",
|
|
29
|
+
className: "mb-8 text-center text-[11px] uppercase tracking-[0.3em]",
|
|
30
|
+
style: { fontFamily: BODY, color: ACCENT },
|
|
31
|
+
})}
|
|
32
|
+
{items.length === 0 ? (
|
|
33
|
+
<p className="text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
|
|
34
|
+
Add logos or publication names.
|
|
35
|
+
</p>
|
|
36
|
+
) : (
|
|
37
|
+
<ul className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
|
38
|
+
{items.map((item, i) => (
|
|
39
|
+
<li key={i} className="flex items-center">
|
|
40
|
+
{item.image ? (
|
|
41
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
42
|
+
<img
|
|
43
|
+
src={item.image}
|
|
44
|
+
alt={item.name}
|
|
45
|
+
loading="lazy"
|
|
46
|
+
className="h-7 w-auto object-contain opacity-70 grayscale"
|
|
47
|
+
/>
|
|
48
|
+
) : (
|
|
49
|
+
<span
|
|
50
|
+
className="text-[13px] uppercase tracking-[0.25em]"
|
|
51
|
+
style={{ fontFamily: BODY, color: MUTED }}
|
|
52
|
+
>
|
|
53
|
+
{item.name}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</li>
|
|
57
|
+
))}
|
|
58
|
+
</ul>
|
|
59
|
+
)}
|
|
60
|
+
</Section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mobile nav island for NavBlock — hamburger button + full-screen overlay menu.
|
|
5
|
+
* Only this ships JS; the desktop nav stays pure server markup. Reads the same
|
|
6
|
+
* `--pb-*` tokens (they inherit into the fixed overlay through the DOM tree).
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { BG, BODY, HEADING, MUTED, TEXT } from "./primitives";
|
|
10
|
+
import type { NavLinkItem } from "./nav";
|
|
11
|
+
|
|
12
|
+
export function NavMenu({
|
|
13
|
+
links,
|
|
14
|
+
logoText,
|
|
15
|
+
light,
|
|
16
|
+
}: {
|
|
17
|
+
links: NavLinkItem[];
|
|
18
|
+
logoText?: string;
|
|
19
|
+
/** Render the trigger in light-on-dark (for transparent navs over photos). */
|
|
20
|
+
light?: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
const onKey = (e: KeyboardEvent) => {
|
|
27
|
+
if (e.key === "Escape") setOpen(false);
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener("keydown", onKey);
|
|
30
|
+
document.body.style.overflow = "hidden";
|
|
31
|
+
return () => {
|
|
32
|
+
window.removeEventListener("keydown", onKey);
|
|
33
|
+
document.body.style.overflow = "";
|
|
34
|
+
};
|
|
35
|
+
}, [open]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="md:hidden">
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
aria-label="Open menu"
|
|
42
|
+
aria-expanded={open}
|
|
43
|
+
onClick={() => setOpen(true)}
|
|
44
|
+
className="flex size-10 flex-col items-center justify-center gap-[5px]"
|
|
45
|
+
>
|
|
46
|
+
<span className="block h-px w-5" style={{ background: light ? "#ffffff" : TEXT }} />
|
|
47
|
+
<span className="block h-px w-5" style={{ background: light ? "#ffffff" : TEXT }} />
|
|
48
|
+
</button>
|
|
49
|
+
|
|
50
|
+
{open && (
|
|
51
|
+
<div
|
|
52
|
+
className="fixed inset-0 z-50 flex flex-col overflow-y-auto px-8 py-6"
|
|
53
|
+
style={{ background: BG, color: TEXT }}
|
|
54
|
+
>
|
|
55
|
+
<div className="flex items-center justify-between">
|
|
56
|
+
<span
|
|
57
|
+
className="text-[13px] uppercase tracking-[0.3em]"
|
|
58
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
59
|
+
>
|
|
60
|
+
{logoText ?? ""}
|
|
61
|
+
</span>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
aria-label="Close menu"
|
|
65
|
+
onClick={() => setOpen(false)}
|
|
66
|
+
className="flex size-10 items-center justify-center text-2xl font-light"
|
|
67
|
+
style={{ color: TEXT }}
|
|
68
|
+
>
|
|
69
|
+
×
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<nav className="mt-12 flex flex-1 flex-col gap-6">
|
|
73
|
+
{links.map((link, i) => (
|
|
74
|
+
<div key={i}>
|
|
75
|
+
<a
|
|
76
|
+
href={link.href || "#"}
|
|
77
|
+
onClick={() => setOpen(false)}
|
|
78
|
+
className="text-3xl font-light"
|
|
79
|
+
style={{ fontFamily: HEADING, color: TEXT }}
|
|
80
|
+
>
|
|
81
|
+
{link.label}
|
|
82
|
+
</a>
|
|
83
|
+
{link.children && link.children.length > 0 && (
|
|
84
|
+
<div className="mt-3 flex flex-col gap-2 pl-1">
|
|
85
|
+
{link.children.map((child, j) => (
|
|
86
|
+
<a
|
|
87
|
+
key={j}
|
|
88
|
+
href={child.href || "#"}
|
|
89
|
+
onClick={() => setOpen(false)}
|
|
90
|
+
className="text-[13px] uppercase tracking-[0.2em]"
|
|
91
|
+
style={{ fontFamily: BODY, color: MUTED }}
|
|
92
|
+
>
|
|
93
|
+
{child.label}
|
|
94
|
+
</a>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</nav>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|