From e36e4b94e4d29cf7af8a511081b823c09232b8ed Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 30 Sep 2026 18:05:33 +0530 Subject: [PATCH] Add spotlight photo feature --- src/App.tsx | 1 + src/components/PhotoWallPresentChrome.tsx | 40 +- src/components/photoWall/MarqueePhotoWall.tsx | 4 + src/components/photoWall/MarqueeRow.tsx | 22 +- src/components/photoWall/PhotoSpotlight.tsx | 17 +- .../photoWall/SpotlightPhotoFrame.tsx | 85 ++ src/hooks/useEventTheme.ts | 6 +- src/lib/api.ts | 27 + src/pages/AiAvatarPage.tsx | 18 +- src/pages/PhotoWallPresentPage.tsx | 55 +- src/pages/SocialSharePage.tsx | 18 +- src/pages/SpotlightPresentPage.tsx | 791 ++++++++++++++++++ 12 files changed, 1059 insertions(+), 25 deletions(-) create mode 100644 src/components/photoWall/SpotlightPhotoFrame.tsx create mode 100644 src/pages/SpotlightPresentPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 02b8869..606f0d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/PhotoWallPresentChrome.tsx b/src/components/PhotoWallPresentChrome.tsx index ce0c9d3..f1de469 100644 --- a/src/components/PhotoWallPresentChrome.tsx +++ b/src/components/PhotoWallPresentChrome.tsx @@ -1,6 +1,6 @@ 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, SpotlightSource } from "../lib/api"; import { formatEventScheduleRange } from "../lib/sessionDisplay"; import { EventBanner } from "./EventBanner"; import { EventBuzzLogo } from "./EventBuzzLogo"; @@ -44,6 +44,9 @@ export function PhotoWallPresentChrome({ onFullscreen, onReplay, isFullscreen = false, + spotlightSources, + onSpotlightSourceChange, + onAllSpotlightSourcesChange, }: { event?: PhotoWallPublicEvent | null; eventHeader?: PhotoWallEventHeaderDisplay | null; @@ -51,6 +54,9 @@ export function PhotoWallPresentChrome({ onFullscreen?: () => void; onReplay?: () => void; isFullscreen?: boolean; + spotlightSources?: Record; + onSpotlightSourceChange?: (source: SpotlightSource, enabled: boolean) => void; + onAllSpotlightSourcesChange?: (enabled: boolean) => void; }) { const [menuOpen, setMenuOpen] = useState(false); const [menuMounted, setMenuMounted] = useState(false); @@ -200,7 +206,7 @@ export function PhotoWallPresentChrome({ id={panelId} role="dialog" aria-modal="true" - aria-label="Photo wall menu" + aria-label={spotlightSources ? "Spotlight menu" : "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" }`} @@ -234,6 +240,36 @@ export function PhotoWallPresentChrome({ Replay + {spotlightSources && onSpotlightSourceChange ? ( +
+

Sources

+ + {([ + ["socialShare", "Social Share"], + ["aiAvatar", "AI Avatar"], + ["quiz", "Quiz"], + ["photoWall", "Photo Wall"], + ] as Array<[SpotlightSource, string]>).map(([source, label]) => ( + + ))} +
+ ) : null} diff --git a/src/components/photoWall/MarqueePhotoWall.tsx b/src/components/photoWall/MarqueePhotoWall.tsx index a0a68d4..c0a9b10 100644 --- a/src/components/photoWall/MarqueePhotoWall.tsx +++ b/src/components/photoWall/MarqueePhotoWall.tsx @@ -5,6 +5,7 @@ import { type PresentPhoto, } from "../../lib/photoWallMarquee"; import { MarqueeRow } from "./MarqueeRow"; +import type { SpotlightFrame } from "./SpotlightPhotoFrame"; type MarqueePhotoWallProps = { rows: PresentPhoto[][]; @@ -22,6 +23,7 @@ type MarqueePhotoWallProps = { paused?: boolean; /** Start endless scroll only after every row is filled to photosPerRow. */ marqueeActive?: boolean; + spotlightFrame?: SpotlightFrame; }; function useViewportWidth() { @@ -51,6 +53,7 @@ export function MarqueePhotoWall({ showNames, paused = false, marqueeActive = false, + spotlightFrame, }: MarqueePhotoWallProps) { const viewportWidth = useViewportWidth(); const available = @@ -84,6 +87,7 @@ export function MarqueePhotoWall({ speed={44 + rowIndex * 6} paused={paused} marqueeActive={marqueeActive} + spotlightFrame={spotlightFrame} /> ))} diff --git a/src/components/photoWall/MarqueeRow.tsx b/src/components/photoWall/MarqueeRow.tsx index c81b52e..84f1164 100644 --- a/src/components/photoWall/MarqueeRow.tsx +++ b/src/components/photoWall/MarqueeRow.tsx @@ -1,6 +1,7 @@ import { useLayoutEffect, useRef } from "react"; import gsap from "gsap"; import type { PresentPhoto } from "../../lib/photoWallMarquee"; +import { SpotlightPhotoFrame, type SpotlightFrame } from "./SpotlightPhotoFrame"; type MarqueeRowProps = { photos: PresentPhoto[]; @@ -16,6 +17,7 @@ type MarqueeRowProps = { paused?: boolean; /** When false, show a static row (fill phase). When true, endless GSAP loop. */ marqueeActive?: boolean; + spotlightFrame?: SpotlightFrame; }; function PhotoCard({ @@ -25,6 +27,7 @@ function PhotoCard({ borderRadius, aspectRatio, showNames, + spotlightFrame, }: { photo: PresentPhoto; cardWidth: number; @@ -32,6 +35,7 @@ function PhotoCard({ borderRadius: number | string; aspectRatio: string; showNames: boolean; + spotlightFrame?: SpotlightFrame; }) { return (
- {photo.displayName + {spotlightFrame ? ( + + ) : ( + {photo.displayName + )}
{showNames && photo.displayName ? (

(null); const trackRef = useRef(null); @@ -210,6 +219,7 @@ export function MarqueeRow({ borderRadius={borderRadius} aspectRatio={aspectRatio} showNames={showNames} + spotlightFrame={spotlightFrame} /> ))} diff --git a/src/components/photoWall/PhotoSpotlight.tsx b/src/components/photoWall/PhotoSpotlight.tsx index 05f17ed..b5847a1 100644 --- a/src/components/photoWall/PhotoSpotlight.tsx +++ b/src/components/photoWall/PhotoSpotlight.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef } from "react"; import gsap from "gsap"; import type { PresentPhoto } from "../../lib/photoWallMarquee"; +import { SpotlightPhotoFrame, type SpotlightFrame } from "./SpotlightPhotoFrame"; type PhotoSpotlightProps = { photo: PresentPhoto | null; borderRadius: number | string; holdMs?: number; onComplete: () => void; + spotlightFrame?: SpotlightFrame; }; /** @@ -18,6 +20,7 @@ export function PhotoSpotlight({ borderRadius, holdMs = 2400, onComplete, + spotlightFrame, }: PhotoSpotlightProps) { const rootRef = useRef(null); const cardRef = useRef(null); @@ -107,13 +110,15 @@ export function PhotoSpotlight({

- {photo.displayName + {spotlightFrame ? : ( + {photo.displayName + )}
diff --git a/src/components/photoWall/SpotlightPhotoFrame.tsx b/src/components/photoWall/SpotlightPhotoFrame.tsx new file mode 100644 index 0000000..6f1c646 --- /dev/null +++ b/src/components/photoWall/SpotlightPhotoFrame.tsx @@ -0,0 +1,85 @@ +import type { PhotoWallPresentData } from '../../lib/api'; +import type { PresentPhoto } from '../../lib/photoWallMarquee'; + +export type SpotlightFrame = Pick & { + frameImageUrl?: string | null; + frameImageLayout?: { x: number; y: number; w: number; h: number }; + frameLayout?: Array>; +}; + +function toPxPercent(value: unknown, size: number): string { + return `${(Number(value || 0) / Math.max(1, size)) * 100}%`; +} + +export function SpotlightPhotoFrame({ + photo, + frame, + borderRadius, +}: { + photo: PresentPhoto; + frame: SpotlightFrame; + borderRadius: number | string; +}) { + const width = frame.frameCanvasWidth || 1080; + const height = frame.frameCanvasHeight || 1080; + const layout = frame.frameLayout || []; + const photoSlot = layout.find((element) => element.field === 'photo'); + const imageLayout = frame.frameImageLayout || { x: 0, y: 0, w: 100, h: 100 }; + const positionedStyle = (element: Record) => ({ + position: 'absolute' as const, + left: toPxPercent(element.x, width), + top: toPxPercent(element.y, height), + width: toPxPercent(element.w ?? width, width), + height: toPxPercent(element.h ?? height, height), + }); + + return ( +
+ {photo.displayName + {frame.frameImageUrl ? ( + + ) : null} + {layout.map((element, index) => { + if (element.field === 'photo') return null; + const isText = element.kind === 'custom_text' || element.field === 'name' || element.field === 'form'; + const text = element.field === 'name' ? photo.displayName || '' : String(element.text || ''); + if (element.kind === 'custom_image' && element.imageUrl) { + return ; + } + if (!isText || !text) return null; + const style = positionedStyle(element); + return ( + + {element.field === 'name' ? `${String(element.prefix || '')}${text}${String(element.postfix || '')}` : text} + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useEventTheme.ts b/src/hooks/useEventTheme.ts index cbf9ba2..fe35a53 100644 --- a/src/hooks/useEventTheme.ts +++ b/src/hooks/useEventTheme.ts @@ -4,6 +4,7 @@ import { getAiAvatarMeta, getPhotoSearchMeta, getPhotoWallPresent, + getSpotlightPresent, getPhotoWallUploadMeta, getPublicVisitorTheme, getSocialShareMeta, @@ -17,7 +18,8 @@ type PublicTokenSource = | "photo-search" | "social-share" | "photo-wall" - | "photo-wall-present"; + | "photo-wall-present" + | "spotlight-present"; type ThemeLookup = | { kind: "none" } @@ -44,6 +46,7 @@ function resolveThemeLookup(pathname: string, searchParams: URLSearchParams): Th { re: /^\/photo-search\/([^/]+)/, source: "photo-search" }, { re: /^\/social-share\/([^/]+)/, source: "social-share" }, { re: /^\/photo-wall-present\/([^/]+)/, source: "photo-wall-present" }, + { re: /^\/spotlight-present\/([^/]+)/, source: "spotlight-present" }, { re: /^\/photo-wall\/([^/]+)/, source: "photo-wall" }, ]; for (const { re, source } of tokenRoutes) { @@ -62,6 +65,7 @@ async function eventIdFromPublicToken(source: PublicTokenSource, token: string): if (source === "photo-search") return (await getPhotoSearchMeta(token)).eventId; if (source === "social-share") return (await getSocialShareMeta(token)).eventId; if (source === "photo-wall") return (await getPhotoWallUploadMeta(token)).eventId; + if (source === "spotlight-present") return (await getSpotlightPresent(token)).eventId; return (await getPhotoWallPresent(token)).eventId; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 4e34f21..4d1038c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1062,6 +1062,15 @@ export type PhotoWallPresentData = { }>; }; +export type SpotlightSource = "socialShare" | "aiAvatar" | "quiz" | "photoWall"; +export type SpotlightSources = Record; +export type SpotlightPresentData = PhotoWallPresentData & { + sources: SpotlightSources; + frameImageUrl?: string | null; + frameLayout?: Array>; + photos: Array; +}; + export async function getPhotoWallUploadMeta(uploadToken: string): Promise { const base = getBaseUrl(); const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`; @@ -1145,6 +1154,20 @@ export async function getPhotoWallPresent(presentToken: string): Promise { + const base = getBaseUrl(); + const query = sources ? `?sources=${encodeURIComponent(sources.join(","))}` : ""; + const url = `${base}/api/public/spotlight/present/${encodeURIComponent(presentToken)}${query}`; + const res = await fetch(url); + const body = await parseJson(res); + if (!res.ok) throw new Error(messageFromBody(body)); + if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response"); + return (body as { data: SpotlightPresentData }).data; +} + export type PhotoSearchMeta = { eventId: string; eventName: string; @@ -1217,6 +1240,7 @@ export async function uploadSocialSharePhoto( uploadToken: string, file: File, frameId: string, + displayName: string, ): Promise<{ previewUrl: string | null; fileUrl: string | null; @@ -1228,6 +1252,7 @@ export async function uploadSocialSharePhoto( const fd = new FormData(); fd.append("file", file, file.name); fd.append("frameId", frameId); + fd.append("displayName", displayName.trim()); const headers: Record = {}; const token = getAttendeeToken(); if (token) headers.Authorization = `Bearer ${token}`; @@ -1292,6 +1317,7 @@ export async function uploadAiAvatarPhoto( uploadToken: string, file: File, avatarId: string, + displayName: string, ): Promise<{ previewUrl: string | null; fileUrl: string | null; @@ -1302,6 +1328,7 @@ export async function uploadAiAvatarPhoto( const fd = new FormData(); fd.append("file", file, file.name); fd.append("avatarId", avatarId); + fd.append("displayName", displayName.trim()); const headers: Record = {}; const token = getAttendeeToken(); if (token) headers.Authorization = `Bearer ${token}`; diff --git a/src/pages/AiAvatarPage.tsx b/src/pages/AiAvatarPage.tsx index 9847338..7c21ec3 100644 --- a/src/pages/AiAvatarPage.tsx +++ b/src/pages/AiAvatarPage.tsx @@ -109,6 +109,7 @@ export function AiAvatarPage() { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [selectedAvatar, setSelectedAvatar] = useState(null); + const [displayName, setDisplayName] = useState(""); const [file, setFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -244,7 +245,7 @@ export function AiAvatarPage() { setErr(null); setActionMsg(null); try { - const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id); + const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id, displayName.trim()); setShareFile(null); setDone({ previewUrl: result.previewUrl, @@ -590,9 +591,22 @@ export function AiAvatarPage() { hasFile={Boolean(file)} onPick={(selected) => void handleFileChange(selected)} /> + +
+ + ); + } + + const photos = data.photos || []; + + const availableSources = (data.availableSources || []).filter( + (source): source is SourceKey => + source in SOURCE_LABELS, + ); + + const columns = Math.max( + 1, + Math.min(12, data.photosPerRow || 4), + ); + + const maxWidth = data.maxPhotoWidth || 320; + const maxHeight = data.maxPhotoHeight || 320; + const gap = data.gapPx ?? 12; + + const marginTop = Math.max( + 0, + data.presentMarginTopPx ?? 0, + ); + + const marginLeft = Math.max( + 0, + data.presentMarginLeftPx ?? 12, + ); + + const marginRight = Math.max( + 0, + data.presentMarginRightPx ?? 12, + ); + + const shape = data.presentPhotoShape || "rounded"; + + const borderRadius = + shape === "rect" + ? 0 + : shape === "circle" + ? "50%" + : 16; + + const aspectRatio = + shape === "circle" + ? "1 / 1" + : data.frameEnabled + ? `${data.frameCanvasWidth || 1080} / ${ + data.frameCanvasHeight || 1080 + }` + : `${maxWidth} / ${maxHeight}`; + + const maxDisplayWidth = + columns * maxWidth + + (columns - 1) * gap + + marginLeft + + marginRight; + + const sourceFilters = [ + { + value: "all", + label: "All", + }, + ...availableSources.map((source) => ({ + value: source, + label: SOURCE_LABELS[source], + })), + ]; + + return ( +
+ {/* Existing event header */} + + + {/* Spotlight Hero */} +
+
+
+ {/* subtle background decoration */} +
+ +
+
+
+ +
+ +
+
+

+ Spotlight +

+ + + + Live + +
+ +

+ Live event moments, shared in real time +

+
+
+ +
+
+
+ {photos.length} +
+
+ {photos.length === 1 ? "Image" : "Images"} +
+
+ +
+ +
+
+
+
+ + +
+ + {/* Temporary connection warning */} + {err ? ( +
+ + Reconnecting to Spotlight… +
+ ) : null} +
+ + {/* Moving Spotlight Gallery */} + {photos.length === 0 ? ( + + ) : ( + + )} +
+ ); +} + +function SpotlightEventHeader({ + data, + menuOpen, + setMenuOpen, + sourceFilters, + selectedSource, + setSelectedSource, +}: { + data: SpotlightPresentData; + menuOpen: boolean; + setMenuOpen: React.Dispatch>; + sourceFilters: { value: string; label: string }[]; + selectedSource: string; + setSelectedSource: React.Dispatch>; +}) { + const event = data.event; + + const eventName = + event?.name?.trim() || + data.eventName || + "Event"; + + const logoSrc = + event?.logoUrl?.trim() || ""; + + const schedule = event?.startDate + ? formatEventScheduleRange( + typeof event.startDate === "string" + ? event.startDate + : String(event.startDate), + event.endDate + ? typeof event.endDate === "string" + ? event.endDate + : String(event.endDate) + : String(event.startDate), + ) + : ""; + + const eventType = + event?.eventType + ? event.eventType.replace( + /^\w/, + (c) => c.toUpperCase(), + ) + : ""; + + const closeMenu = () => { + setMenuOpen(false); + }; + + const selectSource = (value: string) => { + setSelectedSource(value); + closeMenu(); + }; + + return ( +
+ {/* Left: Logo */} +
+ {logoSrc ? ( + {eventName} + ) : ( + + )} +
+ + {/* Center: Event information */} +
+ {eventType ? ( + + {eventType} + + ) : null} + +

+ {eventName} +

+ + {schedule ? ( +
+ + {schedule} +
+ ) : null} +
+ + {/* Right: Menu */} +
+ +
+ + {/* Menu */} + {menuOpen ? ( + <> + + ); + })} + + + + ) : null} +
+ ); +} + +/* ------------------------------------------------------- + Loading +------------------------------------------------------- */ + +function SpotlightLoading() { + return ( +
+
+
+ +
+
+
+
+
+ +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
+
+
+ ); +} + +/* ------------------------------------------------------- + Empty state +------------------------------------------------------- */ + +function SpotlightEmptyState({ + selectedSource, +}: { + selectedSource: string; +}) { + const label = + selectedSource === "all" + ? "Spotlight" + : SOURCE_LABELS[ + selectedSource as keyof typeof SOURCE_LABELS + ] || "this source"; + + return ( +
+
+ +
+ +

+ No images yet +

+ +

+ There are no images in {label} yet. New moments + will appear here automatically. +

+
+ ); +} + +function MovingSpotlightGallery({ + photos, + showName, +}: { + photos: SpotlightPresentData["photos"]; + showName: boolean; +}) { + /* + * Use up to 3 moving rows. + * With fewer photos, automatically reduce the number of rows. + */ + const rowCount = Math.min( + 3, + Math.max(1, Math.ceil(photos.length / 4)), + ); + + const rows = Array.from( + { length: rowCount }, + (_, rowIndex) => + photos.filter( + (_, photoIndex) => + photoIndex % rowCount === rowIndex, + ), + ); + + return ( +
+
+ {rows.map((rowPhotos, rowIndex) => ( + + ))} +
+
+ ); +} + +function MovingSpotlightRow({ + photos, + reverse, + showName, + rowIndex, +}: { + photos: SpotlightPresentData["photos"]; + reverse: boolean; + showName: boolean; + rowIndex: number; +}) { + if (photos.length === 0) return null; + + /* + * Duplicate the photos so the animation can loop + * continuously without an empty gap. + */ + const duplicatedPhotos = [ + ...photos, + ...photos, + ...photos, + ]; + + return ( +
+
+ {duplicatedPhotos.map((photo, index) => ( + + ))} +
+
+ ); +} + +function MovingSpotlightTile({ + photo, + showName, +}: { + photo: SpotlightPresentData["photos"][number]; + showName: boolean; +}) { + const [src, setSrc] = useState(photo.url); + const [failed, setFailed] = useState(false); + + const sourceLabel = + SOURCE_LABELS[ + photo.source as keyof typeof SOURCE_LABELS + ] || "Spotlight"; + + const handleImageError = () => { + if ( + src !== photo.fallbackUrl && + photo.fallbackUrl + ) { + setSrc(photo.fallbackUrl); + return; + } + + setFailed(true); + }; + + return ( +
+
+ {!failed ? ( + { + ) : ( +
+ +
+ )} +
+ + {showName && photo.displayName ? ( +
+ {photo.displayName} +
+ ) : ( +
+ {sourceLabel} +
+ )} +
+ ); +} + +/* ------------------------------------------------------- + Image tile +------------------------------------------------------- */ + +function SpotlightImageTile({ + photo, + maxWidth, + maxHeight, + aspectRatio, + borderRadius, + showName, +}: { + photo: SpotlightPresentData["photos"][number]; + maxWidth: number; + maxHeight: number; + aspectRatio: string; + borderRadius: string | number; + showName: boolean; +}) { + const [src, setSrc] = useState(photo.url); + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + + const sourceLabel = + SOURCE_LABELS[ + photo.source as keyof typeof SOURCE_LABELS + ] || "Spotlight"; + + const handleImageError = () => { + if ( + src !== photo.fallbackUrl && + photo.fallbackUrl + ) { + setSrc(photo.fallbackUrl); + setLoaded(false); + return; + } + + setFailed(true); + }; + + return ( +
+
+ {!failed ? ( + <> + {!loaded ? ( + + +
+ + {showName && photo.displayName + ? photo.displayName + : sourceLabel} + + + + {sourceLabel} + +
+
+ ); +} \ No newline at end of file