From 5dc0d49efae39ee1d61ee5fe95abbb80069572ad Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Wed, 30 Sep 2026 09:39:46 +0530 Subject: [PATCH] Add photo wall marquee presentation layout. Co-authored-by: Cursor --- package-lock.json | 7 + package.json | 1 + src/components/PhotoWallPresentChrome.tsx | 168 ++++++++++--- src/components/photoWall/MarqueePhotoWall.tsx | 91 +++++++ src/components/photoWall/MarqueeRow.tsx | 218 +++++++++++++++++ src/components/photoWall/PhotoSpotlight.tsx | 156 ++++++++++++ src/lib/api.ts | 14 +- src/lib/photoWallMarquee.ts | 62 +++++ src/pages/PhotoWallPresentPage.tsx | 225 ++++++++++++++---- 9 files changed, 858 insertions(+), 84 deletions(-) create mode 100644 src/components/photoWall/MarqueePhotoWall.tsx create mode 100644 src/components/photoWall/MarqueeRow.tsx create mode 100644 src/components/photoWall/PhotoSpotlight.tsx create mode 100644 src/lib/photoWallMarquee.ts diff --git a/package-lock.json b/package-lock.json index 6b89dda..65d4f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f56c082..f667933 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/PhotoWallPresentChrome.tsx b/src/components/PhotoWallPresentChrome.tsx index 5f177c0..ce0c9d3 100644 --- a/src/components/PhotoWallPresentChrome.tsx +++ b/src/components/PhotoWallPresentChrome.tsx @@ -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 ? ( -
+
+
{flags.showLogo ? ( logoSrc ? ( @@ -87,9 +129,7 @@ export function PhotoWallPresentChrome({ ) : ( ) - ) : ( - - )} + ) : null}
{showEventInfo ? (
@@ -104,33 +144,99 @@ export function PhotoWallPresentChrome({ ) : null}
- {showDate ? ( -
- - {schedule} -
- ) : null} - {showLocationKind || showVenue ? ( -
- {showLocationKind ? {locationKind} : null} - {showVenue ? ( - - - {eventLocation} - - ) : null} -
- ) : null} + {showDate ? ( +
+ + {schedule} +
+ ) : null} + {showLocationKind || showVenue ? ( +
+ {showLocationKind ? {locationKind} : null} + {showVenue ? ( + + + {eventLocation} + + ) : null} +
+ ) : null}
-
) : null} -
- ) : null} + + + +
+ {flags.showBanner ? ( - + + ) : null} + + {menuMounted ? ( +
+ +
+ + + ) : null} ); diff --git a/src/components/photoWall/MarqueePhotoWall.tsx b/src/components/photoWall/MarqueePhotoWall.tsx new file mode 100644 index 0000000..a0a68d4 --- /dev/null +++ b/src/components/photoWall/MarqueePhotoWall.tsx @@ -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 ( +
+ {rows.map((rowPhotos, rowIndex) => ( + + ))} +
+ ); +} diff --git a/src/components/photoWall/MarqueeRow.tsx b/src/components/photoWall/MarqueeRow.tsx new file mode 100644 index 0000000..c81b52e --- /dev/null +++ b/src/components/photoWall/MarqueeRow.tsx @@ -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 ( +
+
+ {photo.displayName +
+ {showNames && photo.displayName ? ( +

+ {photo.displayName} +

+ ) : null} +
+ ); +} + +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(null); + const trackRef = useRef(null); + const tweenRef = useRef(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
; + } + + const loopPhotos = marqueeActive ? [...photos, ...photos] : photos; + + return ( +
+ {marqueeActive ? ( + <> +
+
+ + ) : null} +
+ {loopPhotos.map((photo, index) => ( + + ))} +
+
+ ); +} diff --git a/src/components/photoWall/PhotoSpotlight.tsx b/src/components/photoWall/PhotoSpotlight.tsx new file mode 100644 index 0000000..05f17ed --- /dev/null +++ b/src/components/photoWall/PhotoSpotlight.tsx @@ -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(null); + const cardRef = useRef(null); + const frameRef = useRef(null); + const nameRef = useRef(null); + const messageRef = useRef(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 ( +
+
+
+ {photo.displayName +
+ +
+ {photo.displayName ? ( +

+ {photo.displayName} +

+ ) : null} + + {message ? ( +
+ + “ + +

+ {message} +

+ + ” + +
+ ) : null} +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 46dcc9d..4e34f21 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { diff --git a/src/lib/photoWallMarquee.ts b/src/lib/photoWallMarquee.ts new file mode 100644 index 0000000..3335f82 --- /dev/null +++ b/src/lib/photoWallMarquee.ts @@ -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; +} diff --git a/src/pages/PhotoWallPresentPage.tsx b/src/pages/PhotoWallPresentPage.tsx index c5099c4..47f8bef 100644 --- a/src/pages/PhotoWallPresentPage.tsx +++ b/src/pages/PhotoWallPresentPage.tsx @@ -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(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); + const [isFullscreen, setIsFullscreen] = useState(false); + + const [wallPhotos, setWallPhotos] = useState([]); + const [rowPhotos, setRowPhotos] = useState([]); + const [spotlightQueue, setSpotlightQueue] = useState([]); + const [activeSpotlight, setActiveSpotlight] = useState(null); + + const rootRef = useRef(null); + const wallPhotosRef = useRef([]); + const activeSpotlightRef = useRef(null); + const spotlightQueueRef = useRef([]); + 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 ( -
+
void handleFullscreen()} + onReplay={handleReplay} + isFullscreen={isFullscreen} /> - {data.photos.length === 0 ? ( + {wallPhotos.length === 0 && !activeSpotlight && spotlightQueue.length === 0 ? (
Waiting for photos…
) : ( -
- {data.photos.map((p) => ( -
-
- {p.displayName -
- {showNames && p.displayName ? ( -

- {p.displayName} -

- ) : null} -
- ))} -
+ )} + +
); }