Add photo wall marquee presentation layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3bf7c84a9e
commit
5dc0d49efa
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"firebase": "^11.0.2",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "0.487.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
|
|
@ -2672,6 +2673,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"firebase": "^11.0.2",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "0.487.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
|
|
|
|||
|
|
@ -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 { formatEventScheduleRange } from "../lib/sessionDisplay";
|
||||
import { EventBanner } from "./EventBanner";
|
||||
|
|
@ -40,11 +41,21 @@ export function PhotoWallPresentChrome({
|
|||
event,
|
||||
eventHeader,
|
||||
eventNameFallback = "",
|
||||
onFullscreen,
|
||||
onReplay,
|
||||
isFullscreen = false,
|
||||
}: {
|
||||
event?: PhotoWallPublicEvent | null;
|
||||
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||
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 eventName = event?.name?.trim() || eventNameFallback || "Event";
|
||||
const bannerSrc = event?.imageUrl?.trim() || "";
|
||||
|
|
@ -68,14 +79,45 @@ export function PhotoWallPresentChrome({
|
|||
const showVenue = flags.showVenue && Boolean(eventLocation);
|
||||
const showTitle = flags.showTitle && Boolean(eventName);
|
||||
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 (
|
||||
<>
|
||||
{showHeader ? (
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
{flags.showLogo ? (
|
||||
logoSrc ? (
|
||||
|
|
@ -87,9 +129,7 @@ export function PhotoWallPresentChrome({
|
|||
) : (
|
||||
<EventBuzzLogo variant="color" className="h-8 max-h-8 w-auto" />
|
||||
)
|
||||
) : (
|
||||
<span className="block w-0" aria-hidden />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{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">
|
||||
|
|
@ -113,24 +153,90 @@ export function PhotoWallPresentChrome({
|
|||
</div>
|
||||
) : null}
|
||||
{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}
|
||||
{showVenue ? (
|
||||
<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} />
|
||||
<span className="">{eventLocation}</span>
|
||||
<span>{eventLocation}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1030,6 +1030,8 @@ export type PhotoWallUploadMeta = {
|
|||
} | null;
|
||||
};
|
||||
|
||||
export type PhotoWallMarqueeDirection = "left" | "right" | "alternate";
|
||||
|
||||
export type PhotoWallPresentData = {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
|
|
@ -1047,7 +1049,17 @@ export type PhotoWallPresentData = {
|
|||
frameEnabled?: boolean;
|
||||
frameCanvasWidth?: 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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
|
||||
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;
|
||||
|
||||
|
|
@ -24,11 +33,46 @@ function presentShapeStyles(
|
|||
return { borderRadius, aspectRatio, shape: normalized };
|
||||
}
|
||||
|
||||
function oldestFirst(photos: PresentPhoto[]): PresentPhoto[] {
|
||||
return [...photos].reverse();
|
||||
}
|
||||
|
||||
export function PhotoWallPresentPage() {
|
||||
const { presentToken } = useParams<{ presentToken: string }>();
|
||||
const [data, setData] = useState<PhotoWallPresentData | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
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 () => {
|
||||
if (!presentToken) return;
|
||||
|
|
@ -36,19 +80,109 @@ export function PhotoWallPresentPage() {
|
|||
const next = await getPhotoWallPresent(presentToken);
|
||||
setData(next);
|
||||
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) {
|
||||
setErr(e instanceof Error ? e.message : "Photo wall not available");
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [presentToken]);
|
||||
}, [presentToken, seedWall]);
|
||||
|
||||
useEffect(() => {
|
||||
seededRef.current = false;
|
||||
marqueeStartedRef.current = false;
|
||||
setWallPhotos([]);
|
||||
setRowPhotos([]);
|
||||
setSpotlightQueue([]);
|
||||
setActiveSpotlight(null);
|
||||
void refresh();
|
||||
const id = window.setInterval(() => void refresh(), POLL_MS);
|
||||
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) {
|
||||
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 maxH = data.maxPhotoHeight || 320;
|
||||
const gap = data.gapPx ?? 8;
|
||||
|
|
@ -78,6 +211,8 @@ export function PhotoWallPresentPage() {
|
|||
const frameEnabled = Boolean(data.frameEnabled);
|
||||
const frameW = data.frameCanvasWidth || 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(
|
||||
data.presentPhotoShape,
|
||||
maxW,
|
||||
|
|
@ -87,66 +222,52 @@ export function PhotoWallPresentPage() {
|
|||
frameH,
|
||||
);
|
||||
|
||||
const displayRows = rowPhotos.length === totalRows ? rowPhotos : emptyRows(totalRows);
|
||||
if (isMarqueeReady(displayRows, photosPerRow)) {
|
||||
marqueeStartedRef.current = true;
|
||||
}
|
||||
const marqueeActive = marqueeStartedRef.current;
|
||||
|
||||
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
|
||||
event={data.event}
|
||||
eventHeader={data.eventHeader}
|
||||
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">
|
||||
Waiting for photos…
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mx-auto w-full max-w-[100vw] pb-6"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: `${gap}px`,
|
||||
justifyItems: "center",
|
||||
alignItems: "start",
|
||||
paddingTop: `${marginTop}px`,
|
||||
paddingLeft: `${marginLeft}px`,
|
||||
paddingRight: `${marginRight}px`,
|
||||
}}
|
||||
>
|
||||
{data.photos.map((p) => (
|
||||
<div
|
||||
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" }}
|
||||
<MarqueePhotoWall
|
||||
rows={displayRows}
|
||||
marqueeDirection={data.marqueeDirection}
|
||||
photosPerRow={photosPerRow}
|
||||
maxPhotoWidth={maxW}
|
||||
maxPhotoHeight={maxH}
|
||||
gapPx={gap}
|
||||
marginTop={marginTop}
|
||||
marginLeft={marginLeft}
|
||||
marginRight={marginRight}
|
||||
borderRadius={borderRadius}
|
||||
aspectRatio={aspectRatio}
|
||||
showNames={showNames}
|
||||
paused={Boolean(activeSpotlight)}
|
||||
marqueeActive={marqueeActive}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue