Add photo wall marquee presentation layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-09-30 09:39:46 +05:30
parent 3bf7c84a9e
commit 5dc0d49efa
9 changed files with 858 additions and 84 deletions

7
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"firebase": "^11.0.2", "firebase": "^11.0.2",
"gsap": "^3.15.0",
"lucide-react": "0.487.0", "lucide-react": "0.487.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@ -2672,6 +2673,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/http-parser-js": { "node_modules/http-parser-js": {
"version": "0.5.10", "version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"firebase": "^11.0.2", "firebase": "^11.0.2",
"gsap": "^3.15.0",
"lucide-react": "0.487.0", "lucide-react": "0.487.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",

View File

@ -1,4 +1,5 @@
import { Calendar, MapPin } from "lucide-react"; import { useCallback, useEffect, useId, useState } from "react";
import { Calendar, MapPin, Maximize, Menu, RefreshCw, X } from "lucide-react";
import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api"; import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api";
import { formatEventScheduleRange } from "../lib/sessionDisplay"; import { formatEventScheduleRange } from "../lib/sessionDisplay";
import { EventBanner } from "./EventBanner"; import { EventBanner } from "./EventBanner";
@ -40,11 +41,21 @@ export function PhotoWallPresentChrome({
event, event,
eventHeader, eventHeader,
eventNameFallback = "", eventNameFallback = "",
onFullscreen,
onReplay,
isFullscreen = false,
}: { }: {
event?: PhotoWallPublicEvent | null; event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null; eventHeader?: PhotoWallEventHeaderDisplay | null;
eventNameFallback?: string; eventNameFallback?: string;
onFullscreen?: () => void;
onReplay?: () => void;
isFullscreen?: boolean;
}) { }) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuMounted, setMenuMounted] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const panelId = useId();
const flags = resolveEventHeader(eventHeader); const flags = resolveEventHeader(eventHeader);
const eventName = event?.name?.trim() || eventNameFallback || "Event"; const eventName = event?.name?.trim() || eventNameFallback || "Event";
const bannerSrc = event?.imageUrl?.trim() || ""; const bannerSrc = event?.imageUrl?.trim() || "";
@ -68,14 +79,45 @@ export function PhotoWallPresentChrome({
const showVenue = flags.showVenue && Boolean(eventLocation); const showVenue = flags.showVenue && Boolean(eventLocation);
const showTitle = flags.showTitle && Boolean(eventName); const showTitle = flags.showTitle && Boolean(eventName);
const showEventInfo = showTitle || showDate || showType || showLocationKind || showVenue; const showEventInfo = showTitle || showDate || showType || showLocationKind || showVenue;
const showHeader = flags.showLogo || showEventInfo;
if (!showHeader && !flags.showBanner) return null; const closeMenu = useCallback(() => setMenuOpen(false), []);
useEffect(() => {
if (menuOpen) {
setMenuMounted(true);
const id = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => setMenuVisible(true));
});
return () => window.cancelAnimationFrame(id);
}
setMenuVisible(false);
const t = window.setTimeout(() => setMenuMounted(false), 280);
return () => window.clearTimeout(t);
}, [menuOpen]);
useEffect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMenu();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [menuOpen, closeMenu]);
const runFullscreen = () => {
closeMenu();
onFullscreen?.();
};
const runReplay = () => {
closeMenu();
onReplay?.();
};
return ( return (
<> <>
{showHeader ? ( <header className="sticky top-0 z-[70] flex min-h-[64px] w-full items-center justify-between gap-4 bg-[var(--theme-header-color)] px-4 py-3 text-[var(--theme-card-text-color)] shadow-xs sm:px-8">
<header className="flex flex-col items-start justify-start min-h-[64px] w-full sm:flex-row sm:items-center sm:justify-between gap-6 bg-[var(--theme-header-color)] text-[var(--theme-card-text-color)] px-4 py-3 shadow-xs sm:px-8"> <div className="flex min-w-0 flex-1 flex-col items-start justify-start gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 shrink-0 items-center"> <div className="flex min-w-0 shrink-0 items-center">
{flags.showLogo ? ( {flags.showLogo ? (
logoSrc ? ( logoSrc ? (
@ -87,9 +129,7 @@ export function PhotoWallPresentChrome({
) : ( ) : (
<EventBuzzLogo variant="color" className="h-8 max-h-8 w-auto" /> <EventBuzzLogo variant="color" className="h-8 max-h-8 w-auto" />
) )
) : ( ) : null}
<span className="block w-0" aria-hidden />
)}
</div> </div>
{showEventInfo ? ( {showEventInfo ? (
<div className="min-w-0 sm:max-w-[min(640px,58vw)] flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0 sm:max-w-[min(640px,58vw)] flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
@ -104,33 +144,99 @@ export function PhotoWallPresentChrome({
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
{showDate ? ( {showDate ? (
<div <div
className={`flex items-center justify-end gap-2 text-xs font-semibold text-[var(--theme-card-text-color)]/90 ${showTitle ? "mt-1" : ""}`} className={`flex items-center justify-end gap-2 text-xs font-semibold text-[var(--theme-card-text-color)]/90 ${showTitle ? "mt-1" : ""}`}
> >
<Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} /> <Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
<span>{schedule}</span> <span>{schedule}</span>
</div> </div>
) : null} ) : null}
{showLocationKind || showVenue ? ( {showLocationKind || showVenue ? (
<div className="mt-1 flex flex-wrap flex-col items-start gap-x-3 gap-y-1 text-xs font-semibold text-[var(--theme-card-text-color)]/85"> <div className="mt-1 flex flex-col flex-wrap items-start gap-x-3 gap-y-1 text-xs font-semibold text-[var(--theme-card-text-color)]/85">
{showLocationKind ? <span className="font-semibold uppercase">{locationKind}</span> : null} {showLocationKind ? <span className="font-semibold uppercase">{locationKind}</span> : null}
{showVenue ? ( {showVenue ? (
<span className="inline-flex max-w-full items-start gap-1.5 text-left"> <span className="inline-flex max-w-full items-start gap-1.5 text-left">
<MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} /> <MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
<span className="">{eventLocation}</span> <span>{eventLocation}</span>
</span> </span>
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) : null} ) : null}
</header> </div>
) : null}
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-haspopup="dialog"
aria-expanded={menuOpen}
aria-controls={panelId}
className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-[var(--theme-card-text-color)]/15 bg-[var(--theme-card-text-color)]/5 px-3 py-2 text-sm font-semibold text-[var(--theme-card-text-color)] transition hover:bg-[var(--theme-card-text-color)]/10"
>
<Menu className="size-5" strokeWidth={2} aria-hidden />
Menu
</button>
</header>
{flags.showBanner ? ( {flags.showBanner ? (
<EventBanner imageUrl={bannerSrc} alt={eventName} className="w-full shadow-sm mb-3" /> <EventBanner imageUrl={bannerSrc} alt={eventName} className="mb-3 w-full shadow-sm" />
) : null}
{menuMounted ? (
<div className="fixed inset-0 z-[80]" role="presentation">
<button
type="button"
className={`absolute inset-0 bg-black/45 transition-opacity ease-out ${
menuVisible ? "opacity-100" : "opacity-0"
}`}
style={{ transitionDuration: "280ms" }}
aria-label="Close menu"
onClick={closeMenu}
/>
<aside
id={panelId}
role="dialog"
aria-modal="true"
aria-label="Photo wall menu"
className={`absolute inset-y-0 right-0 flex w-[min(320px,92vw)] flex-col bg-[var(--theme-header-color)] text-[var(--theme-card-text-color)] shadow-2xl transition-transform ease-out ${
menuVisible ? "translate-x-0" : "translate-x-full"
}`}
style={{ transitionDuration: "280ms" }}
>
<div className="flex items-center justify-between border-b border-[var(--theme-card-text-color)]/10 px-4 py-4">
<p className="text-sm font-semibold uppercase tracking-wide">Menu</p>
<button
type="button"
onClick={closeMenu}
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-[var(--theme-card-text-color)]/10"
aria-label="Close"
>
<X className="size-5" strokeWidth={2} />
</button>
</div>
<nav className="flex flex-col p-2">
<button
type="button"
onClick={runFullscreen}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition hover:bg-[var(--theme-button-color)]/10"
>
<Maximize className="size-5 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
</button>
<button
type="button"
onClick={runReplay}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition hover:bg-[var(--theme-button-color)]/10"
>
<RefreshCw className="size-5 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
Replay
</button>
</nav>
</aside>
</div>
) : null} ) : null}
</> </>
); );

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import type { PhotoWallMarqueeDirection } from "../../lib/api";
import {
resolveMarqueeDirection,
type PresentPhoto,
} from "../../lib/photoWallMarquee";
import { MarqueeRow } from "./MarqueeRow";
type MarqueePhotoWallProps = {
rows: PresentPhoto[][];
marqueeDirection?: PhotoWallMarqueeDirection;
photosPerRow: number;
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
marginTop: number;
marginLeft: number;
marginRight: number;
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
paused?: boolean;
/** Start endless scroll only after every row is filled to photosPerRow. */
marqueeActive?: boolean;
};
function useViewportWidth() {
const [width, setWidth] = useState(() =>
typeof window !== "undefined" ? window.innerWidth : 1200,
);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
export function MarqueePhotoWall({
rows,
marqueeDirection,
photosPerRow,
maxPhotoWidth,
maxPhotoHeight,
gapPx,
marginTop,
marginLeft,
marginRight,
borderRadius,
aspectRatio,
showNames,
paused = false,
marqueeActive = false,
}: MarqueePhotoWallProps) {
const viewportWidth = useViewportWidth();
const available =
viewportWidth - marginLeft - marginRight - gapPx * Math.max(0, photosPerRow - 1);
const fromCols = Math.floor(available / Math.max(1, photosPerRow));
const cardWidth = Math.max(72, Math.min(maxPhotoWidth, fromCols || maxPhotoWidth));
const cardHeight = Math.min(maxPhotoHeight, cardWidth);
return (
<div
className="flex w-full flex-col justify-center gap-4 sm:gap-5"
style={{
paddingTop: marginTop,
paddingLeft: marginLeft,
paddingRight: marginRight,
paddingBottom: 24,
minHeight: "55dvh",
}}
>
{rows.map((rowPhotos, rowIndex) => (
<MarqueeRow
key={`row-${rowIndex}`}
photos={rowPhotos}
direction={resolveMarqueeDirection(marqueeDirection, rowIndex)}
gapPx={gapPx}
cardWidth={cardWidth}
cardHeight={cardHeight}
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
speed={44 + rowIndex * 6}
paused={paused}
marqueeActive={marqueeActive}
/>
))}
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import type { PresentPhoto } from "../../lib/photoWallMarquee";
type MarqueeRowProps = {
photos: PresentPhoto[];
direction: "left" | "right";
gapPx: number;
cardWidth: number;
cardHeight: number;
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
/** px per second */
speed?: number;
paused?: boolean;
/** When false, show a static row (fill phase). When true, endless GSAP loop. */
marqueeActive?: boolean;
};
function PhotoCard({
photo,
cardWidth,
cardHeight,
borderRadius,
aspectRatio,
showNames,
}: {
photo: PresentPhoto;
cardWidth: number;
cardHeight: number;
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
}) {
return (
<div
className="flex shrink-0 flex-col items-center bg-[var(--theme-card-color)]/50"
style={{ width: cardWidth }}
>
<div
className="relative w-full overflow-hidden shadow-lg"
style={{
width: cardWidth,
maxHeight: cardHeight,
aspectRatio,
borderRadius,
}}
>
<img
src={photo.url}
alt={photo.displayName || "Photo Wall"}
className="h-full w-full object-cover"
draggable={false}
/>
</div>
{showNames && photo.displayName ? (
<p
className="mt-1 w-full truncate px-1 text-center text-xs font-bold text-[var(--theme-card-text-color)]/90 sm:text-sm"
title={photo.displayName}
>
{photo.displayName}
</p>
) : null}
</div>
);
}
function readProgress(tween: gsap.core.Tween | null): number {
if (!tween) return 0;
const p = tween.progress();
return Number.isFinite(p) ? p : 0;
}
/**
* Horizontal photo lane. Static while filling; endless GSAP marquee once active.
* Photo inserts remeasure the loop but keep scroll progress (no visible restart).
*/
export function MarqueeRow({
photos,
direction,
gapPx,
cardWidth,
cardHeight,
borderRadius,
aspectRatio,
showNames,
speed = 48,
paused = false,
marqueeActive = false,
}: MarqueeRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const tweenRef = useRef<gsap.core.Tween | null>(null);
const pausedRef = useRef(paused);
const directionRef = useRef(direction);
const speedRef = useRef(speed);
pausedRef.current = paused;
directionRef.current = direction;
speedRef.current = speed;
const syncLoop = (preserveProgress: boolean) => {
const track = trackRef.current;
if (!track || photos.length === 0) return;
const half = track.scrollWidth / 2;
if (half < 8) return;
const dir = directionRef.current;
const spd = speedRef.current;
const progress = preserveProgress ? readProgress(tweenRef.current) : 0;
const wasPaused = pausedRef.current || Boolean(tweenRef.current?.paused());
tweenRef.current?.kill();
tweenRef.current = null;
const duration = half / Math.max(12, spd);
const fromX = dir === "left" ? 0 : -half;
const toX = dir === "left" ? -half : 0;
const tween = gsap.fromTo(
track,
{ x: fromX },
{
x: toX,
duration,
ease: "none",
repeat: -1,
immediateRender: false,
},
);
tween.progress(progress);
if (wasPaused) tween.pause();
tweenRef.current = tween;
};
const stopLoop = () => {
tweenRef.current?.kill();
tweenRef.current = null;
if (trackRef.current) gsap.set(trackRef.current, { x: 0 });
};
useLayoutEffect(() => {
if (!marqueeActive || photos.length === 0) {
stopLoop();
return;
}
syncLoop(false);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
if (tweenRef.current) syncLoop(true);
})
: null;
if (ro && viewportRef.current) ro.observe(viewportRef.current);
return () => {
ro?.disconnect();
stopLoop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- photos handled separately
}, [marqueeActive, direction]);
useLayoutEffect(() => {
if (!marqueeActive || photos.length === 0) return;
syncLoop(Boolean(tweenRef.current));
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional photo/layout sync
}, [photos, cardWidth, gapPx, speed, marqueeActive]);
useLayoutEffect(() => {
const tween = tweenRef.current;
if (!tween || !marqueeActive) return;
if (paused) tween.pause();
else tween.resume();
}, [paused, marqueeActive]);
if (photos.length === 0) {
return <div className="h-24 w-full" aria-hidden />;
}
const loopPhotos = marqueeActive ? [...photos, ...photos] : photos;
return (
<div ref={viewportRef} className="relative w-full overflow-hidden">
{marqueeActive ? (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-10 bg-gradient-to-r from-black/50 to-transparent sm:w-16"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 bg-gradient-to-l from-black/50 to-transparent sm:w-16"
/>
</>
) : null}
<div
ref={trackRef}
className={`flex w-max will-change-transform ${marqueeActive ? "" : "mx-auto"}`}
style={{ gap: gapPx }}
>
{loopPhotos.map((photo, index) => (
<PhotoCard
key={`${photo._id}-${index < photos.length ? "a" : "b"}`}
photo={photo}
cardWidth={cardWidth}
cardHeight={cardHeight}
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,156 @@
import { useEffect, useRef } from "react";
import gsap from "gsap";
import type { PresentPhoto } from "../../lib/photoWallMarquee";
type PhotoSpotlightProps = {
photo: PresentPhoto | null;
borderRadius: number | string;
holdMs?: number;
onComplete: () => void;
};
/**
* Full-screen spotlight for a newly added photo: fade/scale in → hold → out.
* Optional `message` is shown as a quote (spotlight only — not on wall cards).
*/
export function PhotoSpotlight({
photo,
borderRadius,
holdMs = 2400,
onComplete,
}: PhotoSpotlightProps) {
const rootRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLParagraphElement>(null);
const messageRef = useRef<HTMLQuoteElement>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const message = photo?.message?.trim() || "";
const effectiveHoldMs = message ? Math.max(holdMs, 3200) : holdMs;
useEffect(() => {
if (!photo || !rootRef.current || !cardRef.current || !frameRef.current) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({
onComplete: () => onCompleteRef.current(),
});
gsap.set(rootRef.current, { autoAlpha: 0 });
gsap.set(cardRef.current, { scale: 0.82, autoAlpha: 0 });
gsap.set(frameRef.current, {
boxShadow:
"0 0 0 0 color-mix(in srgb, var(--theme-button-color) 0%, transparent)",
});
if (nameRef.current) {
gsap.set(nameRef.current, { autoAlpha: 0, y: 18 });
}
if (messageRef.current) {
gsap.set(messageRef.current, { autoAlpha: 0, y: 22 });
}
tl.to(rootRef.current, { autoAlpha: 1, duration: 0.35, ease: "power2.out" })
.to(
cardRef.current,
{ scale: 1, autoAlpha: 1, duration: 0.45, ease: "back.out(1.4)" },
"<0.05",
)
.to(
frameRef.current,
{
boxShadow:
"0 0 48px 18px color-mix(in srgb, var(--theme-button-color) 22%, transparent), 0 0 90px 36px color-mix(in srgb, var(--theme-button-color) 12%, transparent), 0 16px 36px rgba(0,0,0,0.35)",
duration: 0.55,
ease: "power2.out",
},
"<0.1",
);
if (nameRef.current) {
tl.to(
nameRef.current,
{ autoAlpha: 1, y: 0, duration: 0.45, ease: "power3.out" },
"-=0.15",
);
}
if (messageRef.current) {
tl.to(
messageRef.current,
{ autoAlpha: 1, y: 0, duration: 0.5, ease: "power3.out" },
"-=0.2",
);
}
tl.to({}, { duration: effectiveHoldMs / 1000 })
.to(cardRef.current, { scale: 0.9, autoAlpha: 0, duration: 0.35, ease: "power2.in" })
.to(rootRef.current, { autoAlpha: 0, duration: 0.25, ease: "power2.in" }, "<");
});
return () => ctx.revert();
}, [photo, effectiveHoldMs]);
if (!photo) return null;
return (
<div
ref={rootRef}
className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-6 backdrop-blur-[2px]"
role="dialog"
aria-label="New photo"
>
<div
ref={cardRef}
className="pointer-events-auto flex w-full max-w-[min(440px,90vw)] flex-col items-center"
>
<div
ref={frameRef}
className="w-full overflow-hidden ring-2 ring-white/35"
style={{ borderRadius, aspectRatio: "1 / 1" }}
>
<img
src={photo.url}
alt={photo.displayName || "New photo"}
className="h-full w-full object-cover"
/>
</div>
<div className="mt-5 flex w-full flex-col items-center px-1 text-center">
{photo.displayName ? (
<p
ref={nameRef}
className="text-[2.1rem] font-bold leading-tight tracking-tight text-[var(--theme-card-text-color)] drop-shadow-md sm:text-[2.45rem]"
style={{ fontFamily: "var(--theme-heading-font, inherit)" }}
>
{photo.displayName}
</p>
) : null}
{message ? (
<blockquote ref={messageRef} className="relative mt-5 max-w-[24rem] px-6">
<span
aria-hidden
className="pointer-events-none absolute -left-1 -top-4 select-none text-6xl font-serif leading-none text-[var(--theme-button-color)]/40"
>
“
</span>
<p
className="text-[1.15rem] italic leading-relaxed text-[var(--theme-card-text-color)]/90 sm:text-[1.3rem]"
style={{ fontFamily: "var(--theme-body-font, Georgia, 'Times New Roman', serif)" }}
>
{message}
</p>
<span
aria-hidden
className="pointer-events-none absolute -bottom-6 right-0 select-none text-6xl font-serif leading-none text-[var(--theme-button-color)]/40"
>
”
</span>
</blockquote>
) : null}
</div>
</div>
</div>
);
}

View File

@ -1030,6 +1030,8 @@ export type PhotoWallUploadMeta = {
} | null; } | null;
}; };
export type PhotoWallMarqueeDirection = "left" | "right" | "alternate";
export type PhotoWallPresentData = { export type PhotoWallPresentData = {
eventId: string; eventId: string;
eventName: string; eventName: string;
@ -1047,7 +1049,17 @@ export type PhotoWallPresentData = {
frameEnabled?: boolean; frameEnabled?: boolean;
frameCanvasWidth?: number; frameCanvasWidth?: number;
frameCanvasHeight?: number; frameCanvasHeight?: number;
photos: Array<{ _id: string; url: string; createdAt?: string; displayName?: string | null }>; /** Number of horizontal marquee lanes. Default 3. */
totalRows?: number;
/** Marquee scroll direction. Default "alternate". */
marqueeDirection?: PhotoWallMarqueeDirection;
photos: Array<{
_id: string;
url: string;
createdAt?: string;
displayName?: string | null;
message?: string | null;
}>;
}; };
export async function getPhotoWallUploadMeta(uploadToken: string): Promise<PhotoWallUploadMeta> { export async function getPhotoWallUploadMeta(uploadToken: string): Promise<PhotoWallUploadMeta> {

View File

@ -0,0 +1,62 @@
import type { PhotoWallMarqueeDirection, PhotoWallPresentData } from "./api";
export type PresentPhoto = PhotoWallPresentData["photos"][number];
export function resolveMarqueeDirection(
direction: PhotoWallMarqueeDirection | undefined,
rowIndex: number,
): "left" | "right" {
const d = direction || "alternate";
if (d === "alternate") return rowIndex % 2 === 0 ? "left" : "right";
return d;
}
export function emptyRows(totalRows: number): PresentPhoto[][] {
return Array.from({ length: Math.max(1, totalRows) }, () => []);
}
/** True when every lane has at least `photosPerRow` cards — marquee can start. */
export function isMarqueeReady(rows: PresentPhoto[][], photosPerRow: number): boolean {
const limit = Math.max(1, photosPerRow);
if (rows.length === 0) return false;
return rows.every((row) => row.length >= limit);
}
/**
* Fill row 0 → row 1 → … until each has `photosPerRow`.
* After that, round-robin: 25th→0, 26th→1, 27th→2, …
*/
export function nextRowIndexForPhoto(rows: PresentPhoto[][], photosPerRow: number): number {
const limit = Math.max(1, photosPerRow);
for (let i = 0; i < rows.length; i++) {
if (rows[i].length < limit) return i;
}
const total = rows.reduce((sum, row) => sum + row.length, 0);
const capacity = rows.length * limit;
return (total - capacity) % rows.length;
}
export function placePhotoInRows(
rows: PresentPhoto[][],
photo: PresentPhoto,
photosPerRow: number,
): PresentPhoto[][] {
if (rows.some((r) => r.some((p) => p._id === photo._id))) return rows;
const lanes = rows.map((r) => [...r]);
const idx = nextRowIndexForPhoto(lanes, photosPerRow);
lanes[idx] = [...lanes[idx], photo];
return lanes;
}
export function seedPhotosIntoRows(
photos: PresentPhoto[],
totalRows: number,
photosPerRow: number,
): PresentPhoto[][] {
let lanes = emptyRows(totalRows);
for (const photo of photos) {
if (!photo._id) continue;
lanes = placePhotoInRows(lanes, photo, photosPerRow);
}
return lanes;
}

View File

@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api"; import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
import { PhotoWallPresentChrome } from "../components/PhotoWallPresentChrome"; import { PhotoWallPresentChrome } from "../components/PhotoWallPresentChrome";
import { MarqueePhotoWall } from "../components/photoWall/MarqueePhotoWall";
import { PhotoSpotlight } from "../components/photoWall/PhotoSpotlight";
import {
emptyRows,
isMarqueeReady,
placePhotoInRows,
seedPhotosIntoRows,
type PresentPhoto,
} from "../lib/photoWallMarquee";
const POLL_MS = 4000; const POLL_MS = 4000;
@ -24,11 +33,46 @@ function presentShapeStyles(
return { borderRadius, aspectRatio, shape: normalized }; return { borderRadius, aspectRatio, shape: normalized };
} }
function oldestFirst(photos: PresentPhoto[]): PresentPhoto[] {
return [...photos].reverse();
}
export function PhotoWallPresentPage() { export function PhotoWallPresentPage() {
const { presentToken } = useParams<{ presentToken: string }>(); const { presentToken } = useParams<{ presentToken: string }>();
const [data, setData] = useState<PhotoWallPresentData | null>(null); const [data, setData] = useState<PhotoWallPresentData | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [wallPhotos, setWallPhotos] = useState<PresentPhoto[]>([]);
const [rowPhotos, setRowPhotos] = useState<PresentPhoto[][]>([]);
const [spotlightQueue, setSpotlightQueue] = useState<PresentPhoto[]>([]);
const [activeSpotlight, setActiveSpotlight] = useState<PresentPhoto | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const wallPhotosRef = useRef<PresentPhoto[]>([]);
const activeSpotlightRef = useRef<PresentPhoto | null>(null);
const spotlightQueueRef = useRef<PresentPhoto[]>([]);
const totalRowsRef = useRef(3);
const photosPerRowRef = useRef(4);
const seededRef = useRef(false);
/** Once every row reaches photosPerRow, keep marquee on. */
const marqueeStartedRef = useRef(false);
wallPhotosRef.current = wallPhotos;
activeSpotlightRef.current = activeSpotlight;
spotlightQueueRef.current = spotlightQueue;
totalRowsRef.current = Math.max(1, data?.totalRows ?? 3);
photosPerRowRef.current = Math.max(1, Math.min(12, data?.photosPerRow || 4));
const seedWall = useCallback((photos: PresentPhoto[], rows: number, perRow: number) => {
const ordered = oldestFirst(photos.filter((p) => Boolean(p._id)));
setWallPhotos(ordered);
setRowPhotos(seedPhotosIntoRows(ordered, rows, perRow));
setSpotlightQueue([]);
setActiveSpotlight(null);
marqueeStartedRef.current = false;
}, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!presentToken) return; if (!presentToken) return;
@ -36,19 +80,109 @@ export function PhotoWallPresentPage() {
const next = await getPhotoWallPresent(presentToken); const next = await getPhotoWallPresent(presentToken);
setData(next); setData(next);
setErr(null); setErr(null);
const rows = Math.max(1, next.totalRows ?? 3);
const perRow = Math.max(1, Math.min(12, next.photosPerRow || 4));
const incoming = next.photos || [];
if (!seededRef.current) {
seededRef.current = true;
seedWall(incoming, rows, perRow);
return;
}
const fresh = incoming.filter((p) => {
if (!p._id) return false;
if (wallPhotosRef.current.some((w) => w._id === p._id)) return false;
if (spotlightQueueRef.current.some((w) => w._id === p._id)) return false;
if (activeSpotlightRef.current?._id === p._id) return false;
return true;
});
if (fresh.length) setSpotlightQueue((q) => [...q, ...oldestFirst(fresh)]);
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : "Photo wall not available"); setErr(e instanceof Error ? e.message : "Photo wall not available");
setData(null); setData(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [presentToken]); }, [presentToken, seedWall]);
useEffect(() => { useEffect(() => {
seededRef.current = false;
marqueeStartedRef.current = false;
setWallPhotos([]);
setRowPhotos([]);
setSpotlightQueue([]);
setActiveSpotlight(null);
void refresh(); void refresh();
const id = window.setInterval(() => void refresh(), POLL_MS); const id = window.setInterval(() => void refresh(), POLL_MS);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [refresh]); }, [presentToken, refresh]);
useEffect(() => {
const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
document.addEventListener("fullscreenchange", onFsChange);
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
const handleFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
const target = rootRef.current ?? document.documentElement;
await target.requestFullscreen();
} catch {
/* browser may block without gesture / unsupported */
}
}, []);
const handleReplay = useCallback(() => {
const lineup = oldestFirst((data?.photos || []).filter((p) => Boolean(p._id)));
if (lineup.length === 0) return;
const rows = Math.max(1, data?.totalRows ?? 3);
marqueeStartedRef.current = false;
activeSpotlightRef.current = null;
wallPhotosRef.current = [];
spotlightQueueRef.current = lineup;
setWallPhotos([]);
setRowPhotos(emptyRows(rows));
setActiveSpotlight(null);
setSpotlightQueue(lineup);
}, [data]);
useEffect(() => {
if (!seededRef.current) return;
setRowPhotos(seedPhotosIntoRows(wallPhotosRef.current, totalRowsRef.current, photosPerRowRef.current));
}, [data?.totalRows, data?.photosPerRow]);
useEffect(() => {
if (activeSpotlight || spotlightQueue.length === 0) return;
setActiveSpotlight(spotlightQueue[0]);
setSpotlightQueue((q) => q.slice(1));
}, [activeSpotlight, spotlightQueue]);
const onSpotlightComplete = useCallback(() => {
const current = activeSpotlightRef.current;
if (!current) {
setActiveSpotlight(null);
return;
}
const perRow = photosPerRowRef.current;
const rows = totalRowsRef.current;
setWallPhotos((prev) => {
if (prev.some((p) => p._id === current._id)) return prev;
return [...prev, current];
});
setRowPhotos((prevRows) => {
const lanes = prevRows.length === rows ? prevRows : emptyRows(rows);
return placePhotoInRows(lanes, current, perRow);
});
setActiveSpotlight(null);
}, []);
if (loading) { if (loading) {
return ( return (
@ -67,7 +201,6 @@ export function PhotoWallPresentPage() {
); );
} }
const cols = Math.max(1, Math.min(12, data.photosPerRow || 4));
const maxW = data.maxPhotoWidth || 320; const maxW = data.maxPhotoWidth || 320;
const maxH = data.maxPhotoHeight || 320; const maxH = data.maxPhotoHeight || 320;
const gap = data.gapPx ?? 8; const gap = data.gapPx ?? 8;
@ -78,6 +211,8 @@ export function PhotoWallPresentPage() {
const frameEnabled = Boolean(data.frameEnabled); const frameEnabled = Boolean(data.frameEnabled);
const frameW = data.frameCanvasWidth || 1080; const frameW = data.frameCanvasWidth || 1080;
const frameH = data.frameCanvasHeight || 1080; const frameH = data.frameCanvasHeight || 1080;
const photosPerRow = Math.max(1, Math.min(12, data.photosPerRow || 4));
const totalRows = Math.max(1, data.totalRows ?? 3);
const { borderRadius, aspectRatio } = presentShapeStyles( const { borderRadius, aspectRatio } = presentShapeStyles(
data.presentPhotoShape, data.presentPhotoShape,
maxW, maxW,
@ -87,66 +222,52 @@ export function PhotoWallPresentPage() {
frameH, frameH,
); );
const displayRows = rowPhotos.length === totalRows ? rowPhotos : emptyRows(totalRows);
if (isMarqueeReady(displayRows, photosPerRow)) {
marqueeStartedRef.current = true;
}
const marqueeActive = marqueeStartedRef.current;
return ( return (
<div className="eb-gradient-auth min-h-dvh text-white"> <div ref={rootRef} className="eb-gradient-auth relative min-h-dvh overflow-x-hidden text-white">
<PhotoWallPresentChrome <PhotoWallPresentChrome
event={data.event} event={data.event}
eventHeader={data.eventHeader} eventHeader={data.eventHeader}
eventNameFallback={data.eventName} eventNameFallback={data.eventName}
onFullscreen={() => void handleFullscreen()}
onReplay={handleReplay}
isFullscreen={isFullscreen}
/> />
{data.photos.length === 0 ? ( {wallPhotos.length === 0 && !activeSpotlight && spotlightQueue.length === 0 ? (
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80"> <div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
Waiting for photos… Waiting for photos…
</div> </div>
) : ( ) : (
<div <MarqueePhotoWall
className="mx-auto w-full max-w-[100vw] pb-6" rows={displayRows}
style={{ marqueeDirection={data.marqueeDirection}
display: "grid", photosPerRow={photosPerRow}
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, maxPhotoWidth={maxW}
gap: `${gap}px`, maxPhotoHeight={maxH}
justifyItems: "center", gapPx={gap}
alignItems: "start", marginTop={marginTop}
paddingTop: `${marginTop}px`, marginLeft={marginLeft}
paddingLeft: `${marginLeft}px`, marginRight={marginRight}
paddingRight: `${marginRight}px`, borderRadius={borderRadius}
}} aspectRatio={aspectRatio}
> showNames={showNames}
{data.photos.map((p) => ( paused={Boolean(activeSpotlight)}
<div marqueeActive={marqueeActive}
key={p._id} />
className="flex w-full flex-col items-center bg-[var(--theme-card-color)]/50"
style={{ maxWidth: maxW }}
>
<div
className="relative z-1 w-full overflow-hidden shadow-lg"
style={{
maxWidth: maxW,
maxHeight: maxH,
aspectRatio,
borderRadius,
}}
>
<img
src={p.url}
alt={p.displayName || "Photo Wall"}
className="h-full w-full object-cover"
style={{ objectFit: "cover" }}
/>
</div>
{showNames && p.displayName ? (
<p
className="mt-1 w-full truncate rounded-md p-2 text-center text-sm font-bold text-[var(--theme-card-text-color)]/90"
title={p.displayName}
>
{p.displayName}
</p>
) : null}
</div>
))}
</div>
)} )}
<PhotoSpotlight
photo={activeSpotlight}
borderRadius={borderRadius}
holdMs={2000}
onComplete={onSpotlightComplete}
/>
</div> </div>
); );
} }