@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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavBlock — the site header as a section. Server-safe: desktop links are pure
|
|
3
|
+
* markup with CSS-only hover/focus dropdowns; only the mobile hamburger menu is
|
|
4
|
+
* a client island (./nav-menu). Elegant, letterspaced small-caps via tokens.
|
|
5
|
+
*
|
|
6
|
+
* `transparent` floats the nav over the NEXT section (absolute, light-on-dark
|
|
7
|
+
* text — pair it with a full-bleed hero). `sticky` pins an opaque nav to the
|
|
8
|
+
* top on scroll (ignored when transparent).
|
|
9
|
+
*/
|
|
10
|
+
import { renderImage, renderText, type EditPrimitives } from "../editable";
|
|
11
|
+
import { NavMenu } from "./nav-menu";
|
|
12
|
+
import { BG, BODY, HAIRLINE, HEADING, TEXT } from "./primitives";
|
|
13
|
+
|
|
14
|
+
export interface NavLinkItem {
|
|
15
|
+
label: string;
|
|
16
|
+
href: string;
|
|
17
|
+
children?: { label: string; href: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NavBlockProps {
|
|
21
|
+
logoText?: string;
|
|
22
|
+
logoImage?: string;
|
|
23
|
+
links?: NavLinkItem[];
|
|
24
|
+
layout?: "split-center" | "left" | "center";
|
|
25
|
+
/** Float over the next section (light text — pair with a photo hero). */
|
|
26
|
+
transparent?: boolean;
|
|
27
|
+
/** Pin an opaque nav to the top on scroll (ignored when transparent). */
|
|
28
|
+
sticky?: boolean;
|
|
29
|
+
edit?: EditPrimitives;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function NavBlock({
|
|
33
|
+
logoText,
|
|
34
|
+
logoImage,
|
|
35
|
+
links = [],
|
|
36
|
+
layout = "left",
|
|
37
|
+
transparent = false,
|
|
38
|
+
sticky = false,
|
|
39
|
+
edit,
|
|
40
|
+
}: NavBlockProps) {
|
|
41
|
+
const color = transparent ? "#ffffff" : TEXT;
|
|
42
|
+
|
|
43
|
+
const logo = logoImage ? (
|
|
44
|
+
renderImage(edit, {
|
|
45
|
+
field: "logoImage",
|
|
46
|
+
src: logoImage,
|
|
47
|
+
alt: logoText ?? "Logo",
|
|
48
|
+
className: "h-8 w-auto object-contain",
|
|
49
|
+
style: transparent ? { filter: "brightness(0) invert(1)" } : undefined,
|
|
50
|
+
})
|
|
51
|
+
) : (
|
|
52
|
+
renderText(edit, {
|
|
53
|
+
field: "logoText",
|
|
54
|
+
value: logoText ?? "",
|
|
55
|
+
as: "span",
|
|
56
|
+
placeholder: "Studio Name",
|
|
57
|
+
className: "text-[15px] uppercase tracking-[0.35em]",
|
|
58
|
+
style: { fontFamily: HEADING, color, fontWeight: 400 },
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const linkList = (items: NavLinkItem[]) => (
|
|
63
|
+
<ul className="flex items-center gap-8">
|
|
64
|
+
{items.map((link, i) => (
|
|
65
|
+
<li key={i} className="group relative">
|
|
66
|
+
<a
|
|
67
|
+
href={link.href || "#"}
|
|
68
|
+
className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
|
|
69
|
+
style={{ fontFamily: BODY, color }}
|
|
70
|
+
>
|
|
71
|
+
{link.label}
|
|
72
|
+
</a>
|
|
73
|
+
{link.children && link.children.length > 0 && (
|
|
74
|
+
<div className="invisible absolute left-1/2 top-full z-30 -translate-x-1/2 pt-3 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
|
|
75
|
+
<div
|
|
76
|
+
className="flex min-w-[180px] flex-col gap-2.5 px-5 py-4"
|
|
77
|
+
style={{
|
|
78
|
+
background: BG,
|
|
79
|
+
border: `1px solid ${HAIRLINE}`,
|
|
80
|
+
borderRadius: "var(--pb-radius, 4px)",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{link.children.map((child, j) => (
|
|
84
|
+
<a
|
|
85
|
+
key={j}
|
|
86
|
+
href={child.href || "#"}
|
|
87
|
+
className="whitespace-nowrap text-[11px] uppercase tracking-[0.2em] transition-opacity hover:opacity-70"
|
|
88
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
89
|
+
>
|
|
90
|
+
{child.label}
|
|
91
|
+
</a>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// split-center: links split around a centered logo. Odd counts weigh left.
|
|
102
|
+
const splitAt = Math.ceil(links.length / 2);
|
|
103
|
+
const inner =
|
|
104
|
+
layout === "center" ? (
|
|
105
|
+
<div className="flex flex-col items-center gap-5">
|
|
106
|
+
{logo}
|
|
107
|
+
<nav aria-label="Site" className="hidden md:block">
|
|
108
|
+
{linkList(links)}
|
|
109
|
+
</nav>
|
|
110
|
+
</div>
|
|
111
|
+
) : layout === "split-center" ? (
|
|
112
|
+
<div className="flex items-center justify-between gap-6">
|
|
113
|
+
<nav aria-label="Site" className="hidden flex-1 justify-end md:flex">
|
|
114
|
+
{linkList(links.slice(0, splitAt))}
|
|
115
|
+
</nav>
|
|
116
|
+
<div className="shrink-0 md:px-6">{logo}</div>
|
|
117
|
+
<nav aria-label="Site" className="hidden flex-1 md:flex">
|
|
118
|
+
{linkList(links.slice(splitAt))}
|
|
119
|
+
</nav>
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<div className="flex items-center justify-between gap-6">
|
|
123
|
+
{logo}
|
|
124
|
+
<nav aria-label="Site" className="hidden md:block">
|
|
125
|
+
{linkList(links)}
|
|
126
|
+
</nav>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const position = transparent
|
|
131
|
+
? "absolute inset-x-0 top-0 z-40"
|
|
132
|
+
: sticky
|
|
133
|
+
? "sticky top-0 z-40"
|
|
134
|
+
: "relative";
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<header
|
|
138
|
+
className={`${position} w-full px-6 py-5 sm:px-10`}
|
|
139
|
+
style={transparent ? undefined : { background: BG, borderBottom: `1px solid ${HAIRLINE}` }}
|
|
140
|
+
>
|
|
141
|
+
<div className="relative mx-auto max-w-6xl">
|
|
142
|
+
{inner}
|
|
143
|
+
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
|
144
|
+
<NavMenu links={links} logoText={logoText} light={transparent} />
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</header>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -22,6 +22,16 @@ export const TINT = "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 4%, transp
|
|
|
22
22
|
/** A hairline border that adapts to the theme. */
|
|
23
23
|
export const HAIRLINE = "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 12%, transparent)";
|
|
24
24
|
|
|
25
|
+
/* ── v0.5 theme treatments (computed by themeVars from buttonStyle/headingCase) ── */
|
|
26
|
+
/** Button surface — primary when the theme is "filled", transparent when "outline". */
|
|
27
|
+
export const BTN_BG = "var(--pb-btn-bg, var(--pb-color-primary, #1a1a1a))";
|
|
28
|
+
/** Button label color matching BTN_BG. */
|
|
29
|
+
export const BTN_FG = "var(--pb-btn-fg, var(--pb-color-bg, #ffffff))";
|
|
30
|
+
/** Button border matching the theme's button treatment. */
|
|
31
|
+
export const BTN_BORDER = "var(--pb-btn-border, var(--pb-color-primary, #1a1a1a))";
|
|
32
|
+
/** Heading text-transform driven by the theme ("none" | "uppercase"). */
|
|
33
|
+
export const HEADING_CASE = "var(--pb-heading-case, none)" as CSSProperties["textTransform"];
|
|
34
|
+
|
|
25
35
|
/** One book, as a block sees it — purely presentational. The app resolves live
|
|
26
36
|
* data (e.g. an author's real books) into this shape before render. */
|
|
27
37
|
export interface BookItem {
|
|
@@ -74,6 +84,50 @@ export function Eyebrow({ children }: { children?: ReactNode }) {
|
|
|
74
84
|
);
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Theme-driven button link (v0.5). Reads the `--pb-btn-*` vars so one component
|
|
89
|
+
* follows the theme's `buttonStyle` (filled/outline). `outline` forces the
|
|
90
|
+
* outline treatment regardless of theme (e.g. teaser cards).
|
|
91
|
+
*/
|
|
92
|
+
export function TokenButton({
|
|
93
|
+
href,
|
|
94
|
+
children,
|
|
95
|
+
outline = false,
|
|
96
|
+
}: {
|
|
97
|
+
href: string;
|
|
98
|
+
children: ReactNode;
|
|
99
|
+
outline?: boolean;
|
|
100
|
+
}) {
|
|
101
|
+
const external = href?.startsWith("http");
|
|
102
|
+
return (
|
|
103
|
+
<a
|
|
104
|
+
href={href || "#"}
|
|
105
|
+
target={external ? "_blank" : undefined}
|
|
106
|
+
rel={external ? "noopener noreferrer" : undefined}
|
|
107
|
+
className="inline-flex items-center justify-center px-6 py-3 text-[12px] font-medium uppercase tracking-[0.2em] transition-opacity hover:opacity-80"
|
|
108
|
+
style={
|
|
109
|
+
outline
|
|
110
|
+
? {
|
|
111
|
+
background: "transparent",
|
|
112
|
+
color: TEXT,
|
|
113
|
+
border: `1px solid ${ACCENT}`,
|
|
114
|
+
borderRadius: RADIUS,
|
|
115
|
+
fontFamily: BODY,
|
|
116
|
+
}
|
|
117
|
+
: {
|
|
118
|
+
background: BTN_BG,
|
|
119
|
+
color: BTN_FG,
|
|
120
|
+
border: `1px solid ${BTN_BORDER}`,
|
|
121
|
+
borderRadius: RADIUS,
|
|
122
|
+
fontFamily: BODY,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</a>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
/** Pill / link button in the theme accent — used for book buy links + CTAs. */
|
|
78
132
|
export function AccentLink({
|
|
79
133
|
href,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Reveal> — scroll-reveal wrapper (v0.5). Fades + slides a section in the
|
|
5
|
+
* first time ~15% of it enters the viewport. Children render immediately (the
|
|
6
|
+
* wrapper is a plain block div — no layout shift, only opacity/transform).
|
|
7
|
+
* Respects `prefers-reduced-motion: reduce` and degrades to a static render
|
|
8
|
+
* when IntersectionObserver is unavailable. The production PageRenderer wraps
|
|
9
|
+
* sections in this when `doc.theme.reveal === true`; the editor canvas never
|
|
10
|
+
* does (reveals would fight inline editing).
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
export interface RevealProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Reveal({ children }: RevealProps) {
|
|
19
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
20
|
+
const [shown, setShown] = useState(false);
|
|
21
|
+
// Becomes true only when we know we can (and should) animate — keeps the
|
|
22
|
+
// server render static so no-JS/reduced-motion visitors see content.
|
|
23
|
+
const [armed, setArmed] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const el = ref.current;
|
|
27
|
+
if (!el) return;
|
|
28
|
+
const reduced =
|
|
29
|
+
typeof window.matchMedia === "function" &&
|
|
30
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
31
|
+
if (reduced || typeof IntersectionObserver === "undefined") {
|
|
32
|
+
setShown(true);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Already in view at hydration (above the fold): stay static instead of
|
|
36
|
+
// hiding then re-animating content the visitor is already reading.
|
|
37
|
+
const rect = el.getBoundingClientRect();
|
|
38
|
+
if (rect.top < window.innerHeight && rect.bottom > 0) {
|
|
39
|
+
setShown(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Sections taller than the viewport can never reach a 0.15 ratio — scale
|
|
43
|
+
// the threshold down so they still reveal.
|
|
44
|
+
const threshold = Math.min(0.15, (window.innerHeight / Math.max(el.offsetHeight, 1)) * 0.15);
|
|
45
|
+
setArmed(true);
|
|
46
|
+
const io = new IntersectionObserver(
|
|
47
|
+
(entries) => {
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.isIntersecting) {
|
|
50
|
+
setShown(true);
|
|
51
|
+
io.disconnect();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ threshold },
|
|
56
|
+
);
|
|
57
|
+
io.observe(el);
|
|
58
|
+
return () => io.disconnect();
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const hidden = armed && !shown;
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
ref={ref}
|
|
65
|
+
style={{
|
|
66
|
+
opacity: hidden ? 0 : 1,
|
|
67
|
+
transform: hidden ? "translateY(24px)" : "none",
|
|
68
|
+
transition: "opacity 500ms ease-out, transform 500ms ease-out",
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SiteFooterBlock — site footer as a section. Variants:
|
|
3
|
+
* - "mega": Instagram thumb strip + handle, nav, socials, bottom bar
|
|
4
|
+
* - "minimal": one-line © + socials
|
|
5
|
+
* - "centered": nav + socials + © stacked and centered
|
|
6
|
+
* Server-safe; the optional back-to-top control is a micro client island.
|
|
7
|
+
* Social links render as letterspaced small-caps text (icon sets don't cover
|
|
8
|
+
* every network consistently — type does, and matches the aesthetic).
|
|
9
|
+
*/
|
|
10
|
+
import { BackToTop } from "./back-to-top";
|
|
11
|
+
import { BODY, HAIRLINE, MUTED, TEXT } from "./primitives";
|
|
12
|
+
|
|
13
|
+
export type SiteFooterSocialKind =
|
|
14
|
+
| "instagram"
|
|
15
|
+
| "facebook"
|
|
16
|
+
| "pinterest"
|
|
17
|
+
| "tiktok"
|
|
18
|
+
| "x"
|
|
19
|
+
| "email";
|
|
20
|
+
|
|
21
|
+
export interface SiteFooterSocial {
|
|
22
|
+
kind: SiteFooterSocialKind;
|
|
23
|
+
href: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SiteFooterNavLink {
|
|
27
|
+
label: string;
|
|
28
|
+
href: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SiteFooterBlockProps {
|
|
32
|
+
variant?: "mega" | "minimal" | "centered";
|
|
33
|
+
instagramHandle?: string;
|
|
34
|
+
/** Thumbnail strip (mega variant) — recent-work square crops. */
|
|
35
|
+
instagramImages?: string[];
|
|
36
|
+
social?: SiteFooterSocial[];
|
|
37
|
+
nav?: SiteFooterNavLink[];
|
|
38
|
+
copyright?: string;
|
|
39
|
+
backToTop?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SOCIAL_LABEL: Record<SiteFooterSocialKind, string> = {
|
|
43
|
+
instagram: "Instagram",
|
|
44
|
+
facebook: "Facebook",
|
|
45
|
+
pinterest: "Pinterest",
|
|
46
|
+
tiktok: "TikTok",
|
|
47
|
+
x: "X",
|
|
48
|
+
email: "Email",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function SocialLinks({ social }: { social: SiteFooterSocial[] }) {
|
|
52
|
+
if (social.length === 0) return null;
|
|
53
|
+
return (
|
|
54
|
+
<ul className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
|
55
|
+
{social.map((s, i) => (
|
|
56
|
+
<li key={i}>
|
|
57
|
+
<a
|
|
58
|
+
href={s.href || "#"}
|
|
59
|
+
target={s.kind === "email" ? undefined : "_blank"}
|
|
60
|
+
rel={s.kind === "email" ? undefined : "noopener noreferrer"}
|
|
61
|
+
className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
|
|
62
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
63
|
+
>
|
|
64
|
+
{SOCIAL_LABEL[s.kind]}
|
|
65
|
+
</a>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function NavLinks({ nav }: { nav: SiteFooterNavLink[] }) {
|
|
73
|
+
if (nav.length === 0) return null;
|
|
74
|
+
return (
|
|
75
|
+
<ul className="flex flex-wrap items-center justify-center gap-x-8 gap-y-2">
|
|
76
|
+
{nav.map((link, i) => (
|
|
77
|
+
<li key={i}>
|
|
78
|
+
<a
|
|
79
|
+
href={link.href || "#"}
|
|
80
|
+
className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
|
|
81
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
82
|
+
>
|
|
83
|
+
{link.label}
|
|
84
|
+
</a>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Copyright({ text }: { text?: string }) {
|
|
92
|
+
return (
|
|
93
|
+
<p className="text-[11px] tracking-[0.1em]" style={{ fontFamily: BODY, color: MUTED }}>
|
|
94
|
+
{text || `© ${new Date().getFullYear()}`}
|
|
95
|
+
</p>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function SiteFooterBlock({
|
|
100
|
+
variant = "centered",
|
|
101
|
+
instagramHandle,
|
|
102
|
+
instagramImages = [],
|
|
103
|
+
social = [],
|
|
104
|
+
nav = [],
|
|
105
|
+
copyright,
|
|
106
|
+
backToTop = false,
|
|
107
|
+
}: SiteFooterBlockProps) {
|
|
108
|
+
if (variant === "minimal") {
|
|
109
|
+
return (
|
|
110
|
+
<footer
|
|
111
|
+
className="w-full px-6 py-8 sm:px-10"
|
|
112
|
+
style={{ borderTop: `1px solid ${HAIRLINE}` }}
|
|
113
|
+
>
|
|
114
|
+
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 sm:flex-row">
|
|
115
|
+
<Copyright text={copyright} />
|
|
116
|
+
<div className="flex items-center gap-6">
|
|
117
|
+
<SocialLinks social={social} />
|
|
118
|
+
{backToTop && <BackToTop />}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</footer>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (variant === "mega") {
|
|
126
|
+
const igHref =
|
|
127
|
+
social.find((s) => s.kind === "instagram")?.href ||
|
|
128
|
+
(instagramHandle ? `https://instagram.com/${instagramHandle.replace(/^@/, "")}` : "#");
|
|
129
|
+
return (
|
|
130
|
+
<footer className="w-full" style={{ borderTop: `1px solid ${HAIRLINE}` }}>
|
|
131
|
+
{instagramImages.length > 0 && (
|
|
132
|
+
<a
|
|
133
|
+
href={igHref}
|
|
134
|
+
target="_blank"
|
|
135
|
+
rel="noopener noreferrer"
|
|
136
|
+
aria-label={instagramHandle ? `Instagram ${instagramHandle}` : "Instagram"}
|
|
137
|
+
className="grid grid-cols-3 sm:grid-cols-6"
|
|
138
|
+
>
|
|
139
|
+
{instagramImages.slice(0, 6).map((src, i) => (
|
|
140
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
141
|
+
<img
|
|
142
|
+
key={i}
|
|
143
|
+
src={src}
|
|
144
|
+
alt=""
|
|
145
|
+
loading="lazy"
|
|
146
|
+
className={`aspect-square w-full object-cover transition-opacity hover:opacity-90 ${i >= 3 ? "hidden sm:block" : ""}`}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</a>
|
|
150
|
+
)}
|
|
151
|
+
<div className="mx-auto flex max-w-6xl flex-col items-center gap-8 px-6 py-14 sm:px-10">
|
|
152
|
+
{instagramHandle && (
|
|
153
|
+
<a
|
|
154
|
+
href={igHref}
|
|
155
|
+
target="_blank"
|
|
156
|
+
rel="noopener noreferrer"
|
|
157
|
+
className="text-[13px] uppercase tracking-[0.35em] transition-opacity hover:opacity-70"
|
|
158
|
+
style={{ fontFamily: BODY, color: TEXT }}
|
|
159
|
+
>
|
|
160
|
+
@{instagramHandle.replace(/^@/, "")}
|
|
161
|
+
</a>
|
|
162
|
+
)}
|
|
163
|
+
<NavLinks nav={nav} />
|
|
164
|
+
<SocialLinks social={social} />
|
|
165
|
+
</div>
|
|
166
|
+
<div className="px-6 py-5 sm:px-10" style={{ borderTop: `1px solid ${HAIRLINE}` }}>
|
|
167
|
+
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 sm:flex-row">
|
|
168
|
+
<Copyright text={copyright} />
|
|
169
|
+
{backToTop && <BackToTop />}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</footer>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// centered
|
|
177
|
+
return (
|
|
178
|
+
<footer
|
|
179
|
+
className="w-full px-6 py-14 sm:px-10"
|
|
180
|
+
style={{ borderTop: `1px solid ${HAIRLINE}` }}
|
|
181
|
+
>
|
|
182
|
+
<div className="mx-auto flex max-w-4xl flex-col items-center gap-7 text-center">
|
|
183
|
+
<NavLinks nav={nav} />
|
|
184
|
+
<SocialLinks social={social} />
|
|
185
|
+
<Copyright text={copyright} />
|
|
186
|
+
{backToTop && <BackToTop />}
|
|
187
|
+
</div>
|
|
188
|
+
</footer>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Slider> — the shared client island behind every sliding block (v0.5:
|
|
5
|
+
* hero-slideshow, testimonial-slider). Owns index state + next/prev + optional
|
|
6
|
+
* autoplay; slides are pre-rendered ReactNodes (server components pass them
|
|
7
|
+
* in), so blocks stay server-safe and only this primitive ships JS.
|
|
8
|
+
*
|
|
9
|
+
* Variants:
|
|
10
|
+
* - "fade": slides stacked, crossfade. With `overlay` set the overlay defines
|
|
11
|
+
* the container height (slides all absolute behind it — hero-style);
|
|
12
|
+
* without it the ACTIVE slide is in-flow and defines the height.
|
|
13
|
+
* - "track": center-dominant filmstrip — fixed-width slides on a translating
|
|
14
|
+
* flex track, neighbors peeking on both sides.
|
|
15
|
+
*
|
|
16
|
+
* Accessibility: arrows are real <button>s (keyboard-operable), slides are
|
|
17
|
+
* labelled groups, inactive fade slides are aria-hidden. Autoplay is disabled
|
|
18
|
+
* under `prefers-reduced-motion` and stops on manual navigation.
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
21
|
+
|
|
22
|
+
export interface SliderProps {
|
|
23
|
+
slides: ReactNode[];
|
|
24
|
+
variant?: "fade" | "track";
|
|
25
|
+
/** Milliseconds between auto-advances. 0/undefined = no autoplay. */
|
|
26
|
+
autoplayMs?: number;
|
|
27
|
+
arrows?: "overlay" | "below" | "none";
|
|
28
|
+
/** Extra classes on the root (size fade sliders here, e.g. `min-h-[70vh]`). */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** Static content rendered above fade slides (defines height — hero copy). */
|
|
31
|
+
overlay?: ReactNode;
|
|
32
|
+
/** CSS length of each track-mode slide, e.g. "min(72vw, 880px)". */
|
|
33
|
+
slideWidth?: string;
|
|
34
|
+
/** Accessible name for the carousel region. */
|
|
35
|
+
label?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TRACK_GAP_PX = 24;
|
|
39
|
+
|
|
40
|
+
function usePrefersReducedMotion(): boolean {
|
|
41
|
+
const [reduced, setReduced] = useState(false);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (typeof window.matchMedia !== "function") return;
|
|
44
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
45
|
+
setReduced(mq.matches);
|
|
46
|
+
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
47
|
+
mq.addEventListener("change", onChange);
|
|
48
|
+
return () => mq.removeEventListener("change", onChange);
|
|
49
|
+
}, []);
|
|
50
|
+
return reduced;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Slider({
|
|
54
|
+
slides,
|
|
55
|
+
variant = "fade",
|
|
56
|
+
autoplayMs,
|
|
57
|
+
arrows = "overlay",
|
|
58
|
+
className = "",
|
|
59
|
+
overlay,
|
|
60
|
+
slideWidth = "min(72vw, 880px)",
|
|
61
|
+
label = "Slideshow",
|
|
62
|
+
}: SliderProps) {
|
|
63
|
+
const count = slides.length;
|
|
64
|
+
const [index, setIndex] = useState(0);
|
|
65
|
+
const interactedRef = useRef(false);
|
|
66
|
+
const reduced = usePrefersReducedMotion();
|
|
67
|
+
|
|
68
|
+
const go = (delta: number, manual = false) => {
|
|
69
|
+
if (manual) interactedRef.current = true;
|
|
70
|
+
setIndex((i) => (i + delta + count) % count);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!autoplayMs || autoplayMs <= 0 || count < 2 || reduced) return;
|
|
75
|
+
const t = window.setInterval(() => {
|
|
76
|
+
if (!interactedRef.current) setIndex((i) => (i + 1) % count);
|
|
77
|
+
}, autoplayMs);
|
|
78
|
+
return () => window.clearInterval(t);
|
|
79
|
+
}, [autoplayMs, count, reduced]);
|
|
80
|
+
|
|
81
|
+
if (count === 0) return null;
|
|
82
|
+
const duration = reduced ? "0ms" : undefined;
|
|
83
|
+
const showArrows = count > 1 && arrows !== "none";
|
|
84
|
+
|
|
85
|
+
const arrowButtons = (overlayStyle: boolean) => (
|
|
86
|
+
<>
|
|
87
|
+
<ArrowButton dir="prev" overlay={overlayStyle} onClick={() => go(-1, true)} />
|
|
88
|
+
<ArrowButton dir="next" overlay={overlayStyle} onClick={() => go(1, true)} />
|
|
89
|
+
</>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (variant === "track") {
|
|
93
|
+
const w = `(${slideWidth})`;
|
|
94
|
+
return (
|
|
95
|
+
<section aria-roledescription="carousel" aria-label={label} className={className}>
|
|
96
|
+
<div className="overflow-hidden">
|
|
97
|
+
<div
|
|
98
|
+
className="flex transition-transform duration-500 ease-out"
|
|
99
|
+
style={{
|
|
100
|
+
gap: `${TRACK_GAP_PX}px`,
|
|
101
|
+
transform: `translateX(calc(50% - ${w} / 2 - ${index} * (${w} + ${TRACK_GAP_PX}px)))`,
|
|
102
|
+
transitionDuration: duration,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{slides.map((slide, i) => (
|
|
106
|
+
<div
|
|
107
|
+
key={i}
|
|
108
|
+
role="group"
|
|
109
|
+
aria-roledescription="slide"
|
|
110
|
+
aria-label={`${i + 1} of ${count}`}
|
|
111
|
+
className="shrink-0 transition-opacity duration-500"
|
|
112
|
+
style={{ width: slideWidth, opacity: i === index ? 1 : 0.45, transitionDuration: duration }}
|
|
113
|
+
>
|
|
114
|
+
{slide}
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
{showArrows && (
|
|
120
|
+
<div className="mt-6 flex justify-center gap-3">{arrowButtons(false)}</div>
|
|
121
|
+
)}
|
|
122
|
+
</section>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// fade
|
|
127
|
+
const sizedByOverlay = overlay != null;
|
|
128
|
+
return (
|
|
129
|
+
<section
|
|
130
|
+
aria-roledescription="carousel"
|
|
131
|
+
aria-label={label}
|
|
132
|
+
className={`relative overflow-hidden ${className}`}
|
|
133
|
+
>
|
|
134
|
+
{slides.map((slide, i) => {
|
|
135
|
+
const active = i === index;
|
|
136
|
+
const style: CSSProperties = {
|
|
137
|
+
opacity: active ? 1 : 0,
|
|
138
|
+
transition: "opacity 700ms ease-out",
|
|
139
|
+
transitionDuration: duration,
|
|
140
|
+
pointerEvents: active ? undefined : "none",
|
|
141
|
+
};
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
key={i}
|
|
145
|
+
role="group"
|
|
146
|
+
aria-roledescription="slide"
|
|
147
|
+
aria-label={`${i + 1} of ${count}`}
|
|
148
|
+
aria-hidden={!active}
|
|
149
|
+
className={sizedByOverlay || !active ? "absolute inset-0" : "relative"}
|
|
150
|
+
style={style}
|
|
151
|
+
>
|
|
152
|
+
{slide}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
{overlay != null && <div className="relative z-10">{overlay}</div>}
|
|
157
|
+
{showArrows && arrows === "overlay" && (
|
|
158
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-20 flex -translate-y-1/2 justify-between px-4">
|
|
159
|
+
{arrowButtons(true)}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
{showArrows && arrows === "below" && (
|
|
163
|
+
<div className="relative z-10 mt-6 flex justify-center gap-3">{arrowButtons(false)}</div>
|
|
164
|
+
)}
|
|
165
|
+
</section>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ArrowButton({
|
|
170
|
+
dir,
|
|
171
|
+
overlay,
|
|
172
|
+
onClick,
|
|
173
|
+
}: {
|
|
174
|
+
dir: "prev" | "next";
|
|
175
|
+
overlay: boolean;
|
|
176
|
+
onClick: () => void;
|
|
177
|
+
}) {
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={onClick}
|
|
182
|
+
aria-label={dir === "prev" ? "Previous slide" : "Next slide"}
|
|
183
|
+
className={
|
|
184
|
+
overlay
|
|
185
|
+
? "pointer-events-auto flex size-10 items-center justify-center rounded-full bg-black/30 text-lg text-white transition hover:bg-black/50"
|
|
186
|
+
: "flex size-10 items-center justify-center rounded-full border text-lg transition hover:opacity-70"
|
|
187
|
+
}
|
|
188
|
+
style={
|
|
189
|
+
overlay
|
|
190
|
+
? undefined
|
|
191
|
+
: {
|
|
192
|
+
borderColor: "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 25%, transparent)",
|
|
193
|
+
color: "var(--pb-color-text, #1a1a1a)",
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
<span aria-hidden>{dir === "prev" ? "‹" : "›"}</span>
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
}
|