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": {
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -113,24 +153,90 @@ export function PhotoWallPresentChrome({
|
||||||
</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}
|
||||||
|
</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>
|
</header>
|
||||||
) : null}
|
|
||||||
{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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
} | 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> {
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue