@morphika/andami 0.3.1 → 0.4.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/app/admin/pages/[slug]/page.tsx +2 -2
- package/components/blocks/BlockRenderer.tsx +21 -42
- package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
- package/components/builder/BlockLivePreview.tsx +18 -42
- package/components/builder/ReadOnlyFrame.tsx +4 -23
- package/components/builder/SectionCardIcons.tsx +54 -0
- package/components/builder/SectionTypePicker.tsx +1 -1
- package/components/builder/blockStyles.tsx +6 -0
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
- package/components/builder/editors/index.ts +1 -0
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
- package/components/builder/live-preview/index.ts +1 -0
- package/components/builder/settings-panel/BlockSettings.tsx +18 -63
- package/lib/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/builder/block-registrations.ts +335 -0
- package/lib/builder/block-registry.ts +195 -0
- package/lib/builder/defaults.ts +22 -81
- package/lib/builder/index.ts +16 -0
- package/lib/builder/registry.ts +44 -0
- package/lib/builder/store-sections.ts +1 -1
- package/lib/builder/types.ts +8 -3
- package/lib/sanity/types.ts +50 -1
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/index.ts +2 -1
- package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
- package/sanity/schemas/index.ts +4 -1
- package/sanity/schemas/pageSectionV2.ts +1 -0
|
@@ -415,10 +415,10 @@ export default function PageEditorPage() {
|
|
|
415
415
|
[store]
|
|
416
416
|
);
|
|
417
417
|
|
|
418
|
-
// Handle add page section (project grid)
|
|
418
|
+
// Handle add page section (project grid / project carousel)
|
|
419
419
|
// afterRowKey=null → always appends to end of page (bottom "Add Section" button)
|
|
420
420
|
const handleAddSection = useCallback(
|
|
421
|
-
(blockType: "projectGridBlock") => {
|
|
421
|
+
(blockType: "projectGridBlock" | "projectCarouselBlock") => {
|
|
422
422
|
store.addSection(blockType, null);
|
|
423
423
|
setShowSectionPicker(false);
|
|
424
424
|
},
|
|
@@ -17,13 +17,8 @@ import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
|
17
17
|
import HoverAnimationWrapper from "./HoverAnimationWrapper";
|
|
18
18
|
import TypewriterWrapper from "./TypewriterWrapper";
|
|
19
19
|
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import ImageGridBlockRenderer from "./ImageGridBlockRenderer";
|
|
23
|
-
import VideoBlockRenderer from "./VideoBlockRenderer";
|
|
24
|
-
import SpacerBlockRenderer from "./SpacerBlockRenderer";
|
|
25
|
-
import ButtonBlockRenderer from "./ButtonBlockRenderer";
|
|
26
|
-
import ProjectGridBlockRenderer from "./ProjectGridBlockRenderer";
|
|
20
|
+
import { getTextBlockStyles } from "./TextBlockRenderer";
|
|
21
|
+
import { getBlockRegistration } from "../../lib/builder/registry";
|
|
27
22
|
|
|
28
23
|
// ── BLK-003: Error Boundary for block renderers ──
|
|
29
24
|
// Prevents a single broken block from crashing the entire page.
|
|
@@ -288,41 +283,25 @@ export default function BlockRenderer({
|
|
|
288
283
|
|
|
289
284
|
let content: React.ReactNode;
|
|
290
285
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
case "projectGridBlock":
|
|
311
|
-
content = <ProjectGridBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectGridBlock} />;
|
|
312
|
-
break;
|
|
313
|
-
default: {
|
|
314
|
-
const unknownBlock = resolved as ContentBlock;
|
|
315
|
-
if (process.env.NODE_ENV === "development") {
|
|
316
|
-
content = (
|
|
317
|
-
<div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
|
|
318
|
-
Unknown block type: {unknownBlock._type}
|
|
319
|
-
</div>
|
|
320
|
-
);
|
|
321
|
-
} else {
|
|
322
|
-
// BLK-004: Log unknown block types in production for debugging
|
|
323
|
-
console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
286
|
+
// Registry-driven dispatch. Same result as the former switch-case — the
|
|
287
|
+
// Session B consistency tests guarantee registry.renderer === the imported
|
|
288
|
+
// component that was previously inlined in each case branch.
|
|
289
|
+
const registration = getBlockRegistration(resolved._type);
|
|
290
|
+
if (registration) {
|
|
291
|
+
const Renderer = registration.renderer as React.ComponentType<{ block: ContentBlock }>;
|
|
292
|
+
content = <Renderer block={resolved} />;
|
|
293
|
+
} else {
|
|
294
|
+
const unknownBlock = resolved as ContentBlock;
|
|
295
|
+
if (process.env.NODE_ENV === "development") {
|
|
296
|
+
content = (
|
|
297
|
+
<div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
|
|
298
|
+
Unknown block type: {unknownBlock._type}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
} else {
|
|
302
|
+
// BLK-004: Log unknown block types in production for debugging
|
|
303
|
+
console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
|
|
304
|
+
return null;
|
|
326
305
|
}
|
|
327
306
|
}
|
|
328
307
|
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProjectCarouselBlockRenderer — Public site renderer for a horizontal
|
|
5
|
+
* carousel of projects ("keep browsing" pattern at the end of a project
|
|
6
|
+
* page, but usable anywhere).
|
|
7
|
+
*
|
|
8
|
+
* Selection is automatic (latest / random), with optional exclusion of the
|
|
9
|
+
* project currently being viewed (detected from the pathname on
|
|
10
|
+
* `/work/[slug]` pages). Fetch endpoint is shared with `ProjectGridBlock`
|
|
11
|
+
* (`/api/projects`) but no code is linked between the two — changing one
|
|
12
|
+
* renderer never breaks the other.
|
|
13
|
+
*
|
|
14
|
+
* Carousel is native CSS scroll-snap with JS-driven arrow / dot controls
|
|
15
|
+
* and a ResizeObserver-measured card width that respects a fractional
|
|
16
|
+
* `cards_per_view` (e.g. 3.5 shows half of the next card to hint "more").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
useState,
|
|
21
|
+
useEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
useCallback,
|
|
24
|
+
useMemo,
|
|
25
|
+
memo,
|
|
26
|
+
} from "react";
|
|
27
|
+
import Link from "next/link";
|
|
28
|
+
import { usePathname } from "next/navigation";
|
|
29
|
+
import { useAssetUrl, useThumbUrl } from "../../lib/contexts/AssetContext";
|
|
30
|
+
import { useViewport } from "../../lib/hooks/useViewport";
|
|
31
|
+
import type { ProjectCarouselBlock } from "../../lib/sanity/types";
|
|
32
|
+
import type { DeviceViewport } from "../../lib/builder/types";
|
|
33
|
+
|
|
34
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface ApiProject {
|
|
37
|
+
_id?: string;
|
|
38
|
+
slug: string | { current: string };
|
|
39
|
+
title?: string;
|
|
40
|
+
seo_description?: string;
|
|
41
|
+
thumbnail_path?: string;
|
|
42
|
+
cover_video?: string;
|
|
43
|
+
published_at?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ResolvedProject {
|
|
47
|
+
slug: string;
|
|
48
|
+
title: string;
|
|
49
|
+
subtitle: string;
|
|
50
|
+
thumbnail_path?: string;
|
|
51
|
+
cover_video?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** Fisher–Yates shuffle, seeded by Math.random. In-place for simplicity. */
|
|
57
|
+
function shuffleInPlace<T>(arr: T[]): T[] {
|
|
58
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
59
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
60
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
61
|
+
}
|
|
62
|
+
return arr;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalize a slug that may come as `"foo"` or `{ current: "foo" }`. */
|
|
66
|
+
function extractSlug(slug: string | { current: string } | undefined | null): string | null {
|
|
67
|
+
if (!slug) return null;
|
|
68
|
+
return typeof slug === "string" ? slug : slug.current ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Extract the project slug from a /work/[slug] pathname (or return null). */
|
|
72
|
+
export function extractCurrentProjectSlug(pathname: string | null): string | null {
|
|
73
|
+
if (!pathname) return null;
|
|
74
|
+
const match = pathname.match(/^\/work\/([^/?#]+)/);
|
|
75
|
+
return match ? match[1] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve how many cards should fit in the viewport given the block config. */
|
|
79
|
+
export function resolveCardsPerView(
|
|
80
|
+
block: Pick<ProjectCarouselBlock, "cards_per_view_desktop" | "cards_per_view_tablet" | "cards_per_view_phone">,
|
|
81
|
+
viewport: DeviceViewport,
|
|
82
|
+
): number {
|
|
83
|
+
if (viewport === "phone") return block.cards_per_view_phone ?? 1.2;
|
|
84
|
+
if (viewport === "tablet") return block.cards_per_view_tablet ?? 2.2;
|
|
85
|
+
return block.cards_per_view_desktop ?? 3.5;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Compute card pixel width given container width, cards-per-view, and gap. */
|
|
89
|
+
export function computeCardWidth(
|
|
90
|
+
containerWidth: number,
|
|
91
|
+
cardsPerView: number,
|
|
92
|
+
gap: number,
|
|
93
|
+
): number {
|
|
94
|
+
if (cardsPerView <= 0 || containerWidth <= 0) return 0;
|
|
95
|
+
// cpv cards + (cpv - 1) gaps = containerWidth → W = (cw - (cpv-1)*gap) / cpv
|
|
96
|
+
const w = (containerWidth - (cardsPerView - 1) * gap) / cardsPerView;
|
|
97
|
+
return Math.max(0, w);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Pick the final list of projects to render based on the block's source config. */
|
|
101
|
+
export function selectCarouselProjects(
|
|
102
|
+
all: ResolvedProject[],
|
|
103
|
+
block: Pick<ProjectCarouselBlock, "source_mode" | "max_projects" | "exclude_current">,
|
|
104
|
+
currentProjectSlug: string | null,
|
|
105
|
+
): ResolvedProject[] {
|
|
106
|
+
const filtered =
|
|
107
|
+
block.exclude_current !== false && currentProjectSlug
|
|
108
|
+
? all.filter((p) => p.slug !== currentProjectSlug)
|
|
109
|
+
: all;
|
|
110
|
+
|
|
111
|
+
const ordered =
|
|
112
|
+
block.source_mode === "auto_random"
|
|
113
|
+
? shuffleInPlace([...filtered])
|
|
114
|
+
: filtered;
|
|
115
|
+
|
|
116
|
+
const count = Math.max(1, Math.min(20, block.max_projects ?? 8));
|
|
117
|
+
return ordered.slice(0, count);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Card component ──────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const ProjectCard = memo(function ProjectCard({
|
|
123
|
+
project,
|
|
124
|
+
aspectRatio,
|
|
125
|
+
showTitle,
|
|
126
|
+
showSubtitle,
|
|
127
|
+
borderRadius,
|
|
128
|
+
hoverEffect,
|
|
129
|
+
videoMode,
|
|
130
|
+
cardWidth,
|
|
131
|
+
entranceStyle,
|
|
132
|
+
assetUrl,
|
|
133
|
+
thumbUrl,
|
|
134
|
+
}: {
|
|
135
|
+
project: ResolvedProject;
|
|
136
|
+
aspectRatio: string;
|
|
137
|
+
showTitle: boolean;
|
|
138
|
+
showSubtitle: boolean;
|
|
139
|
+
borderRadius: number;
|
|
140
|
+
hoverEffect: "scale" | "none";
|
|
141
|
+
videoMode: "off" | "hover" | "autoloop";
|
|
142
|
+
cardWidth: number;
|
|
143
|
+
entranceStyle: React.CSSProperties;
|
|
144
|
+
assetUrl: (p: string) => string;
|
|
145
|
+
thumbUrl: (p: string) => string;
|
|
146
|
+
}) {
|
|
147
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
148
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
149
|
+
|
|
150
|
+
const thumbSrc = project.thumbnail_path ? thumbUrl(project.thumbnail_path) : null;
|
|
151
|
+
const videoSrc = project.cover_video ? assetUrl(project.cover_video) : null;
|
|
152
|
+
|
|
153
|
+
// Play/pause video according to mode
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const video = videoRef.current;
|
|
156
|
+
if (!video || videoMode === "off" || !videoSrc) return;
|
|
157
|
+
if (videoMode === "autoloop") {
|
|
158
|
+
video.play().catch(() => {});
|
|
159
|
+
} else if (videoMode === "hover") {
|
|
160
|
+
if (isHovered) video.play().catch(() => {});
|
|
161
|
+
else { video.pause(); video.currentTime = 0; }
|
|
162
|
+
}
|
|
163
|
+
}, [isHovered, videoMode, videoSrc]);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<Link
|
|
167
|
+
href={`/work/${project.slug}`}
|
|
168
|
+
className="flex flex-col no-underline group"
|
|
169
|
+
style={{
|
|
170
|
+
flex: `0 0 ${cardWidth}px`,
|
|
171
|
+
scrollSnapAlign: "start",
|
|
172
|
+
...entranceStyle,
|
|
173
|
+
}}
|
|
174
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
175
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
176
|
+
>
|
|
177
|
+
{/* Thumbnail area */}
|
|
178
|
+
<div
|
|
179
|
+
className="relative w-full overflow-hidden bg-black/5"
|
|
180
|
+
style={{
|
|
181
|
+
aspectRatio: aspectRatio.replace("/", " / "),
|
|
182
|
+
borderRadius: borderRadius ? `${borderRadius}px` : undefined,
|
|
183
|
+
transform: hoverEffect === "scale" && isHovered ? "scale(1.02)" : "scale(1)",
|
|
184
|
+
transition: "transform 300ms cubic-bezier(0.22, 1, 0.36, 1)",
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{thumbSrc && (
|
|
188
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
189
|
+
<img
|
|
190
|
+
src={thumbSrc}
|
|
191
|
+
alt={project.title || ""}
|
|
192
|
+
loading="lazy"
|
|
193
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
{videoSrc && videoMode !== "off" && (
|
|
197
|
+
<video
|
|
198
|
+
ref={videoRef}
|
|
199
|
+
src={videoSrc}
|
|
200
|
+
muted
|
|
201
|
+
loop
|
|
202
|
+
playsInline
|
|
203
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
204
|
+
style={{
|
|
205
|
+
opacity: videoMode === "autoloop" || (videoMode === "hover" && isHovered) ? 1 : 0,
|
|
206
|
+
transition: "opacity 200ms ease",
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Title + subtitle */}
|
|
213
|
+
{(showTitle || showSubtitle) && (
|
|
214
|
+
<div className="mt-3 px-0.5">
|
|
215
|
+
{showTitle && project.title && (
|
|
216
|
+
<p className="text-[14px] font-medium text-[var(--brand-text,#111)] leading-tight truncate">
|
|
217
|
+
{project.title}
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
{showSubtitle && project.subtitle && (
|
|
221
|
+
<p className="text-[12px] text-[var(--brand-text-muted,#888)] leading-snug mt-0.5 truncate">
|
|
222
|
+
{project.subtitle}
|
|
223
|
+
</p>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</Link>
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── Main component ──────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export default function ProjectCarouselBlockRenderer({
|
|
234
|
+
block,
|
|
235
|
+
}: {
|
|
236
|
+
block: ProjectCarouselBlock;
|
|
237
|
+
}) {
|
|
238
|
+
const assetUrl = useAssetUrl();
|
|
239
|
+
const thumbUrl = useThumbUrl();
|
|
240
|
+
const viewport = useViewport();
|
|
241
|
+
const pathname = usePathname();
|
|
242
|
+
|
|
243
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
244
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
245
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
246
|
+
|
|
247
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
248
|
+
const [allProjects, setAllProjects] = useState<ResolvedProject[]>([]);
|
|
249
|
+
const [fetchError, setFetchError] = useState(false);
|
|
250
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
251
|
+
const [isHoveringTrack, setIsHoveringTrack] = useState(false);
|
|
252
|
+
|
|
253
|
+
// ── Config resolved ────────────────────────────────────────────
|
|
254
|
+
const gap = block.gap ?? 16;
|
|
255
|
+
const aspectRatio = block.aspect_ratio ?? "4/3";
|
|
256
|
+
const showTitle = block.show_title !== false;
|
|
257
|
+
const showSubtitle = block.show_subtitle === true;
|
|
258
|
+
const borderRadius = block.border_radius ?? 0;
|
|
259
|
+
const hoverEffect = block.hover_effect ?? "scale";
|
|
260
|
+
const videoMode = block.video_mode ?? "off";
|
|
261
|
+
const showArrows = block.show_arrows !== false;
|
|
262
|
+
const showDots = block.show_dots === true;
|
|
263
|
+
const snapScroll = block.snap_scroll !== false;
|
|
264
|
+
const cardEntrance = block.card_entrance;
|
|
265
|
+
|
|
266
|
+
const cardsPerView = resolveCardsPerView(block, viewport);
|
|
267
|
+
const cardWidth = useMemo(
|
|
268
|
+
() => computeCardWidth(containerWidth, cardsPerView, gap),
|
|
269
|
+
[containerWidth, cardsPerView, gap],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const currentSlug = extractCurrentProjectSlug(pathname);
|
|
273
|
+
|
|
274
|
+
const projects = useMemo(
|
|
275
|
+
() => selectCarouselProjects(allProjects, block, currentSlug),
|
|
276
|
+
[allProjects, block, currentSlug],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// ── Measure container width via ResizeObserver ─────────────────
|
|
280
|
+
const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
|
281
|
+
if (roRef.current) {
|
|
282
|
+
roRef.current.disconnect();
|
|
283
|
+
roRef.current = null;
|
|
284
|
+
}
|
|
285
|
+
containerRef.current = node;
|
|
286
|
+
if (!node) return;
|
|
287
|
+
|
|
288
|
+
const ro = new ResizeObserver((entries) => {
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
291
|
+
if (w > 0) setContainerWidth(w);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
roRef.current = ro;
|
|
295
|
+
ro.observe(node);
|
|
296
|
+
|
|
297
|
+
const measure = () => {
|
|
298
|
+
const w = node.clientWidth;
|
|
299
|
+
if (w > 0) setContainerWidth(w);
|
|
300
|
+
};
|
|
301
|
+
measure();
|
|
302
|
+
requestAnimationFrame(measure);
|
|
303
|
+
}, []);
|
|
304
|
+
|
|
305
|
+
useEffect(
|
|
306
|
+
() => () => {
|
|
307
|
+
if (roRef.current) {
|
|
308
|
+
roRef.current.disconnect();
|
|
309
|
+
roRef.current = null;
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
[],
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// ── Fetch project data ─────────────────────────────────────────
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
setFetchError(false);
|
|
318
|
+
fetch("/api/projects")
|
|
319
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`API ${r.status}`))))
|
|
320
|
+
.then((data: { projects?: ApiProject[] }) => {
|
|
321
|
+
const resolved: ResolvedProject[] = [];
|
|
322
|
+
for (const proj of data.projects || []) {
|
|
323
|
+
const slug = extractSlug(proj.slug);
|
|
324
|
+
if (!slug) continue;
|
|
325
|
+
resolved.push({
|
|
326
|
+
slug,
|
|
327
|
+
title: proj.title || slug,
|
|
328
|
+
subtitle: proj.seo_description || "",
|
|
329
|
+
thumbnail_path: proj.thumbnail_path,
|
|
330
|
+
cover_video: proj.cover_video,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
setAllProjects(resolved);
|
|
334
|
+
})
|
|
335
|
+
.catch(() => setFetchError(true));
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
// ── Track scroll position → active index (for dots + arrow disabling) ──
|
|
339
|
+
const handleScroll = useCallback(() => {
|
|
340
|
+
const track = trackRef.current;
|
|
341
|
+
if (!track || cardWidth === 0) return;
|
|
342
|
+
const idx = Math.round(track.scrollLeft / (cardWidth + gap));
|
|
343
|
+
setActiveIndex(Math.max(0, Math.min(projects.length - 1, idx)));
|
|
344
|
+
}, [cardWidth, gap, projects.length]);
|
|
345
|
+
|
|
346
|
+
// ── Arrow controls ─────────────────────────────────────────────
|
|
347
|
+
const scrollByPage = useCallback((dir: -1 | 1) => {
|
|
348
|
+
const track = trackRef.current;
|
|
349
|
+
if (!track) return;
|
|
350
|
+
const step = (cardWidth + gap) * Math.max(1, Math.floor(cardsPerView));
|
|
351
|
+
track.scrollBy({ left: step * dir, behavior: "smooth" });
|
|
352
|
+
}, [cardWidth, gap, cardsPerView]);
|
|
353
|
+
|
|
354
|
+
const scrollToIndex = useCallback((idx: number) => {
|
|
355
|
+
const track = trackRef.current;
|
|
356
|
+
if (!track) return;
|
|
357
|
+
track.scrollTo({ left: (cardWidth + gap) * idx, behavior: "smooth" });
|
|
358
|
+
}, [cardWidth, gap]);
|
|
359
|
+
|
|
360
|
+
// ── Error state ────────────────────────────────────────────────
|
|
361
|
+
if (fetchError) {
|
|
362
|
+
return (
|
|
363
|
+
<div className="py-8 text-center text-sm text-neutral-500">
|
|
364
|
+
Unable to load projects
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (projects.length === 0) {
|
|
370
|
+
// Either still loading (transient) or no projects after exclusion
|
|
371
|
+
return (
|
|
372
|
+
<div ref={containerCallbackRef} className="relative w-full">
|
|
373
|
+
{/* Reserve space so the carousel doesn't jump once projects load */}
|
|
374
|
+
<div style={{ aspectRatio: `${aspectRatio.replace("/", " / ")}` }} />
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Arrow disabled states ──────────────────────────────────────
|
|
380
|
+
const canScrollPrev = activeIndex > 0;
|
|
381
|
+
const canScrollNext = activeIndex < projects.length - Math.floor(cardsPerView);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div
|
|
385
|
+
ref={containerCallbackRef}
|
|
386
|
+
className="relative w-full"
|
|
387
|
+
onMouseEnter={() => setIsHoveringTrack(true)}
|
|
388
|
+
onMouseLeave={() => setIsHoveringTrack(false)}
|
|
389
|
+
>
|
|
390
|
+
{/* Scroll track */}
|
|
391
|
+
<div
|
|
392
|
+
ref={trackRef}
|
|
393
|
+
onScroll={handleScroll}
|
|
394
|
+
className="flex overflow-x-auto no-scrollbar"
|
|
395
|
+
style={{
|
|
396
|
+
gap: `${gap}px`,
|
|
397
|
+
scrollSnapType: snapScroll ? "x mandatory" : undefined,
|
|
398
|
+
scrollBehavior: "smooth",
|
|
399
|
+
WebkitOverflowScrolling: "touch",
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
{projects.map((project, idx) => {
|
|
403
|
+
const enabled = cardEntrance?.enabled;
|
|
404
|
+
const preset = cardEntrance?.preset ?? "slide-up";
|
|
405
|
+
const delay = (cardEntrance?.stagger_delay ?? 80) * idx;
|
|
406
|
+
const duration = cardEntrance?.duration ?? 500;
|
|
407
|
+
const entranceStyle: React.CSSProperties = enabled
|
|
408
|
+
? {
|
|
409
|
+
animation: `carousel-card-${preset} ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms both`,
|
|
410
|
+
}
|
|
411
|
+
: {};
|
|
412
|
+
return (
|
|
413
|
+
<ProjectCard
|
|
414
|
+
key={project.slug}
|
|
415
|
+
project={project}
|
|
416
|
+
aspectRatio={aspectRatio}
|
|
417
|
+
showTitle={showTitle}
|
|
418
|
+
showSubtitle={showSubtitle}
|
|
419
|
+
borderRadius={borderRadius}
|
|
420
|
+
hoverEffect={hoverEffect}
|
|
421
|
+
videoMode={videoMode}
|
|
422
|
+
cardWidth={cardWidth}
|
|
423
|
+
entranceStyle={entranceStyle}
|
|
424
|
+
assetUrl={assetUrl}
|
|
425
|
+
thumbUrl={thumbUrl}
|
|
426
|
+
/>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
{/* Arrow controls — visible on hover (desktop), hidden on touch */}
|
|
432
|
+
{showArrows && projects.length > Math.floor(cardsPerView) && (
|
|
433
|
+
<>
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
onClick={() => scrollByPage(-1)}
|
|
437
|
+
disabled={!canScrollPrev}
|
|
438
|
+
aria-label="Previous projects"
|
|
439
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 backdrop-blur-sm border border-black/10 shadow-md flex items-center justify-center transition-opacity disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white"
|
|
440
|
+
style={{
|
|
441
|
+
opacity: isHoveringTrack ? 1 : 0,
|
|
442
|
+
pointerEvents: isHoveringTrack ? "auto" : "none",
|
|
443
|
+
transition: "opacity 200ms ease",
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
447
|
+
<polyline points="15 18 9 12 15 6" />
|
|
448
|
+
</svg>
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={() => scrollByPage(1)}
|
|
453
|
+
disabled={!canScrollNext}
|
|
454
|
+
aria-label="Next projects"
|
|
455
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 backdrop-blur-sm border border-black/10 shadow-md flex items-center justify-center transition-opacity disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white"
|
|
456
|
+
style={{
|
|
457
|
+
opacity: isHoveringTrack ? 1 : 0,
|
|
458
|
+
pointerEvents: isHoveringTrack ? "auto" : "none",
|
|
459
|
+
transition: "opacity 200ms ease",
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
463
|
+
<polyline points="9 18 15 12 9 6" />
|
|
464
|
+
</svg>
|
|
465
|
+
</button>
|
|
466
|
+
</>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Dots */}
|
|
470
|
+
{showDots && projects.length > 1 && (
|
|
471
|
+
<div className="flex justify-center gap-1.5 mt-4">
|
|
472
|
+
{projects.map((p, idx) => (
|
|
473
|
+
<button
|
|
474
|
+
key={p.slug}
|
|
475
|
+
type="button"
|
|
476
|
+
onClick={() => scrollToIndex(idx)}
|
|
477
|
+
aria-label={`Go to project ${idx + 1}`}
|
|
478
|
+
className="transition-all"
|
|
479
|
+
style={{
|
|
480
|
+
width: activeIndex === idx ? 24 : 6,
|
|
481
|
+
height: 6,
|
|
482
|
+
borderRadius: 999,
|
|
483
|
+
background:
|
|
484
|
+
activeIndex === idx
|
|
485
|
+
? "var(--brand-text, #111)"
|
|
486
|
+
: "var(--brand-text-muted, #888)",
|
|
487
|
+
opacity: activeIndex === idx ? 0.9 : 0.3,
|
|
488
|
+
}}
|
|
489
|
+
/>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
|
|
494
|
+
{/* Inline keyframes for card entrance presets — scoped per-render
|
|
495
|
+
through unique animation names so multiple carousels co-exist. */}
|
|
496
|
+
<style>{CAROUSEL_KEYFRAMES}</style>
|
|
497
|
+
|
|
498
|
+
{/* Hide native scrollbar without breaking scrollability */}
|
|
499
|
+
<style>{SCROLLBAR_HIDE_CSS}</style>
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ─── Scoped styles ───────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
const CAROUSEL_KEYFRAMES = `
|
|
507
|
+
@keyframes carousel-card-fade {
|
|
508
|
+
from { opacity: 0; }
|
|
509
|
+
to { opacity: 1; }
|
|
510
|
+
}
|
|
511
|
+
@keyframes carousel-card-slide-up {
|
|
512
|
+
from { opacity: 0; transform: translateY(24px); }
|
|
513
|
+
to { opacity: 1; transform: translateY(0); }
|
|
514
|
+
}
|
|
515
|
+
@keyframes carousel-card-scale {
|
|
516
|
+
from { opacity: 0; transform: scale(0.94); }
|
|
517
|
+
to { opacity: 1; transform: scale(1); }
|
|
518
|
+
}
|
|
519
|
+
@media (prefers-reduced-motion: reduce) {
|
|
520
|
+
[class*="carousel-card-"] { animation: none !important; }
|
|
521
|
+
}
|
|
522
|
+
`;
|
|
523
|
+
|
|
524
|
+
const SCROLLBAR_HIDE_CSS = `
|
|
525
|
+
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
526
|
+
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
527
|
+
`;
|