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 ? (
-
+
- ) : 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 (
+
+
+

+
+ {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}
+
+ ) : 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) => (
-
-
-

-
- {showNames && p.displayName ? (
-
- {p.displayName}
-
- ) : null}
-
- ))}
-
+
)}
+
+
);
}