Add spotlight photo feature

This commit is contained in:
Tanuja Pansare 2026-09-30 18:05:33 +05:30
parent 5dc0d49efa
commit e36e4b94e4
12 changed files with 1059 additions and 25 deletions

View File

@ -53,6 +53,7 @@ export default function App() {
<Route path="/questions-present/:sessionId" element={<QuestionsPresentPage />} />
<Route path="/photo-wall/:uploadToken" element={<PhotoWallUploadPage />} />
<Route path="/photo-wall-present/:presentToken" element={<PhotoWallPresentPage />} />
<Route path="/spotlight-present/:presentToken" element={<PhotoWallPresentPage spotlightMode />} />
<Route path="/photo-search/:searchToken" element={<PhotoSearchPage />} />
<Route path="/social-share/:uploadToken" element={<SocialSharePage />} />
<Route path="/ai-avatar/:uploadToken" element={<AiAvatarPage />} />

View File

@ -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<SpotlightSource, boolean>;
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({
<RefreshCw className="size-5 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
Replay
</button>
{spotlightSources && onSpotlightSourceChange ? (
<div className="mt-2 border-t border-[var(--theme-card-text-color)]/10 px-4 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide opacity-70">Sources</p>
<label className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-semibold transition hover:bg-[var(--theme-button-color)]/10">
<input
type="checkbox"
checked={Object.values(spotlightSources).every(Boolean)}
onChange={(event) => onAllSpotlightSourcesChange?.(event.target.checked)}
className="size-4 accent-[var(--theme-button-color)]"
/>
All sources
</label>
{([
["socialShare", "Social Share"],
["aiAvatar", "AI Avatar"],
["quiz", "Quiz"],
["photoWall", "Photo Wall"],
] as Array<[SpotlightSource, string]>).map(([source, label]) => (
<label key={source} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-medium transition hover:bg-[var(--theme-button-color)]/10">
<input
type="checkbox"
checked={spotlightSources[source]}
onChange={(event) => onSpotlightSourceChange(source, event.target.checked)}
className="size-4 accent-[var(--theme-button-color)]"
/>
{label}
</label>
))}
</div>
) : null}
</nav>
</aside>
</div>

View File

@ -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}
/>
))}
</div>

View File

@ -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 (
<div
@ -47,12 +51,16 @@ function PhotoCard({
borderRadius,
}}
>
<img
src={photo.url}
alt={photo.displayName || "Photo Wall"}
className="h-full w-full object-cover"
draggable={false}
/>
{spotlightFrame ? (
<SpotlightPhotoFrame photo={photo} frame={spotlightFrame} borderRadius={borderRadius} />
) : (
<img
src={photo.url}
alt={photo.displayName || "Photo Wall"}
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
{showNames && photo.displayName ? (
<p
@ -88,6 +96,7 @@ export function MarqueeRow({
speed = 48,
paused = false,
marqueeActive = false,
spotlightFrame,
}: MarqueeRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@ -210,6 +219,7 @@ export function MarqueeRow({
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
spotlightFrame={spotlightFrame}
/>
))}
</div>

View File

@ -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<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
@ -107,13 +110,15 @@ export function PhotoSpotlight({
<div
ref={frameRef}
className="w-full overflow-hidden ring-2 ring-white/35"
style={{ borderRadius, aspectRatio: "1 / 1" }}
style={{ borderRadius, aspectRatio: spotlightFrame ? `${spotlightFrame.frameCanvasWidth || 1080} / ${spotlightFrame.frameCanvasHeight || 1080}` : "1 / 1" }}
>
<img
src={photo.url}
alt={photo.displayName || "New photo"}
className="h-full w-full object-cover"
/>
{spotlightFrame ? <SpotlightPhotoFrame photo={photo} frame={spotlightFrame} borderRadius={borderRadius} /> : (
<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">

View File

@ -0,0 +1,85 @@
import type { PhotoWallPresentData } from '../../lib/api';
import type { PresentPhoto } from '../../lib/photoWallMarquee';
export type SpotlightFrame = Pick<PhotoWallPresentData, 'frameCanvasWidth' | 'frameCanvasHeight'> & {
frameImageUrl?: string | null;
frameImageLayout?: { x: number; y: number; w: number; h: number };
frameLayout?: Array<Record<string, unknown>>;
};
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<string, unknown>) => ({
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 (
<div className="relative h-full w-full overflow-hidden" style={{ aspectRatio: `${width} / ${height}`, borderRadius, containerType: 'inline-size' }}>
<img
src={photo.url}
alt={photo.displayName || 'Spotlight photo'}
className={photoSlot ? 'absolute object-cover' : 'absolute inset-0 h-full w-full object-cover'}
style={photoSlot ? positionedStyle(photoSlot) : undefined}
draggable={false}
/>
{frame.frameImageUrl ? (
<img
src={frame.frameImageUrl}
alt=""
className="pointer-events-none absolute object-fill"
style={{ left: `${imageLayout.x}%`, top: `${imageLayout.y}%`, width: `${imageLayout.w}%`, height: `${imageLayout.h}%` }}
draggable={false}
/>
) : 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 <img key={String(element.id || index)} src={String(element.imageUrl)} alt="" className="pointer-events-none object-contain" style={positionedStyle(element)} />;
}
if (!isText || !text) return null;
const style = positionedStyle(element);
return (
<span
key={String(element.id || index)}
className="pointer-events-none flex items-center overflow-hidden px-1"
style={{
...style,
color: String(element.color || '#ffffff'),
fontSize: `${(Number(element.fontSizePx || 36) / width) * 100}cqi`,
fontWeight: element.fontWeight === 'bold' ? 700 : 400,
fontFamily: String(element.fontFamily || 'inherit'),
textAlign: String(element.textAlign || 'left') as 'left' | 'center' | 'right',
justifyContent: element.textAlign === 'center' ? 'center' : element.textAlign === 'right' ? 'flex-end' : 'flex-start',
whiteSpace: 'pre-wrap',
lineHeight: 1.1,
}}
>
{element.field === 'name' ? `${String(element.prefix || '')}${text}${String(element.postfix || '')}` : text}
</span>
);
})}
</div>
);
}

View File

@ -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;
}

View File

@ -1062,6 +1062,15 @@ export type PhotoWallPresentData = {
}>;
};
export type SpotlightSource = "socialShare" | "aiAvatar" | "quiz" | "photoWall";
export type SpotlightSources = Record<SpotlightSource, boolean>;
export type SpotlightPresentData = PhotoWallPresentData & {
sources: SpotlightSources;
frameImageUrl?: string | null;
frameLayout?: Array<Record<string, unknown>>;
photos: Array<PhotoWallPresentData["photos"][number] & { source: SpotlightSource }>;
};
export async function getPhotoWallUploadMeta(uploadToken: string): Promise<PhotoWallUploadMeta> {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`;
@ -1145,6 +1154,20 @@ export async function getPhotoWallPresent(presentToken: string): Promise<PhotoWa
return (body as { data: PhotoWallPresentData }).data;
}
export async function getSpotlightPresent(
presentToken: string,
sources?: SpotlightSource[],
): Promise<SpotlightPresentData> {
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<string, string> = {};
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<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;

View File

@ -109,6 +109,7 @@ export function AiAvatarPage() {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedAvatar, setSelectedAvatar] = useState<AiAvatarPublicTemplate | null>(null);
const [displayName, setDisplayName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(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)}
/>
<label htmlFor="ai-avatar-display-name" className="flex flex-col gap-1.5 text-sm font-medium text-[var(--theme-card-text-color)]">
Your name
<input
id="ai-avatar-display-name"
type="text"
autoComplete="name"
required
maxLength={80}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="h-11 w-full rounded-lg border border-[var(--theme-card-text-color)]/20 bg-[var(--theme-card-color)] px-3 text-sm font-normal outline-none focus:border-[var(--theme-button-color)]"
/>
</label>
<button
type="button"
disabled={!file || submitting}
disabled={!file || !displayName.trim() || displayName.trim().length > 80 || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
>

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
import { getPhotoWallPresent, getSpotlightPresent, type PhotoWallPresentData, type SpotlightPresentData, type SpotlightSource, type SpotlightSources } from "../lib/api";
import { PhotoWallPresentChrome } from "../components/PhotoWallPresentChrome";
import { MarqueePhotoWall } from "../components/photoWall/MarqueePhotoWall";
import { PhotoSpotlight } from "../components/photoWall/PhotoSpotlight";
import type { SpotlightFrame } from "../components/photoWall/SpotlightPhotoFrame";
import {
emptyRows,
isMarqueeReady,
@ -37,9 +38,14 @@ function oldestFirst(photos: PresentPhoto[]): PresentPhoto[] {
return [...photos].reverse();
}
export function PhotoWallPresentPage() {
export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?: boolean }) {
const { presentToken } = useParams<{ presentToken: string }>();
const [data, setData] = useState<PhotoWallPresentData | null>(null);
const [data, setData] = useState<(PhotoWallPresentData & {
sources?: SpotlightSources;
frameImageUrl?: string | null;
frameImageLayout?: { x: number; y: number; w: number; h: number };
frameLayout?: Array<Record<string, unknown>>;
}) | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
@ -48,6 +54,9 @@ export function PhotoWallPresentPage() {
const [rowPhotos, setRowPhotos] = useState<PresentPhoto[][]>([]);
const [spotlightQueue, setSpotlightQueue] = useState<PresentPhoto[]>([]);
const [activeSpotlight, setActiveSpotlight] = useState<PresentPhoto | null>(null);
const [selectedSources, setSelectedSources] = useState<SpotlightSources>({ socialShare: true, aiAvatar: true, quiz: true, photoWall: true });
const sourcePreferencesInitialized = useRef(false);
const selectedSourceQuery = (Object.keys(selectedSources) as SpotlightSource[]).filter((source) => selectedSources[source]).join(",");
const rootRef = useRef<HTMLDivElement>(null);
const wallPhotosRef = useRef<PresentPhoto[]>([]);
@ -77,9 +86,15 @@ export function PhotoWallPresentPage() {
const refresh = useCallback(async () => {
if (!presentToken) return;
try {
const next = await getPhotoWallPresent(presentToken);
const next = spotlightMode
? await getSpotlightPresent(presentToken, selectedSourceQuery.split(",").filter(Boolean) as SpotlightSource[])
: await getPhotoWallPresent(presentToken);
setData(next);
setErr(null);
if (spotlightMode && !sourcePreferencesInitialized.current && "sources" in next && next.sources) {
sourcePreferencesInitialized.current = true;
setSelectedSources({ ...(next as SpotlightPresentData).sources });
}
const rows = Math.max(1, next.totalRows ?? 3);
const perRow = Math.max(1, Math.min(12, next.photosPerRow || 4));
@ -106,19 +121,33 @@ export function PhotoWallPresentPage() {
} finally {
setLoading(false);
}
}, [presentToken, seedWall]);
}, [presentToken, seedWall, spotlightMode, selectedSourceQuery]);
useEffect(() => {
seededRef.current = false;
sourcePreferencesInitialized.current = false;
marqueeStartedRef.current = false;
setWallPhotos([]);
setRowPhotos([]);
setSpotlightQueue([]);
setActiveSpotlight(null);
}, [presentToken]);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), POLL_MS);
return () => window.clearInterval(id);
}, [presentToken, refresh]);
}, [refresh]);
const changeSource = useCallback((source: SpotlightSource, enabled: boolean) => {
seededRef.current = false;
setSelectedSources((current) => ({ ...current, [source]: enabled }));
}, []);
const changeAllSources = useCallback((enabled: boolean) => {
seededRef.current = false;
setSelectedSources({ socialShare: enabled, aiAvatar: enabled, quiz: enabled, photoWall: enabled });
}, []);
useEffect(() => {
const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
@ -227,6 +256,15 @@ export function PhotoWallPresentPage() {
marqueeStartedRef.current = true;
}
const marqueeActive = marqueeStartedRef.current;
const spotlightFrame: SpotlightFrame | undefined = spotlightMode && data.frameEnabled
? {
frameCanvasWidth: data.frameCanvasWidth,
frameCanvasHeight: data.frameCanvasHeight,
frameImageUrl: data.frameImageUrl,
frameImageLayout: data.frameImageLayout,
frameLayout: data.frameLayout,
}
: undefined;
return (
<div ref={rootRef} className="eb-gradient-auth relative min-h-dvh overflow-x-hidden text-white">
@ -237,6 +275,9 @@ export function PhotoWallPresentPage() {
onFullscreen={() => void handleFullscreen()}
onReplay={handleReplay}
isFullscreen={isFullscreen}
spotlightSources={spotlightMode ? selectedSources : undefined}
onSpotlightSourceChange={spotlightMode ? changeSource : undefined}
onAllSpotlightSourcesChange={spotlightMode ? changeAllSources : undefined}
/>
{wallPhotos.length === 0 && !activeSpotlight && spotlightQueue.length === 0 ? (
@ -259,6 +300,7 @@ export function PhotoWallPresentPage() {
showNames={showNames}
paused={Boolean(activeSpotlight)}
marqueeActive={marqueeActive}
spotlightFrame={spotlightFrame}
/>
)}
@ -267,6 +309,7 @@ export function PhotoWallPresentPage() {
borderRadius={borderRadius}
holdMs={2000}
onComplete={onSpotlightComplete}
spotlightFrame={spotlightFrame}
/>
</div>
);

View File

@ -94,6 +94,7 @@ export function SocialSharePage() {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedFrame, setSelectedFrame] = useState<SocialSharePublicFrame | null>(null);
const [displayName, setDisplayName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@ -235,7 +236,7 @@ export function SocialSharePage() {
setErr(null);
setActionMsg(null);
try {
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id, displayName.trim());
setShareFile(null);
setResultImageReady(false);
setDone({
@ -571,9 +572,22 @@ export function SocialSharePage() {
hasFile={Boolean(file)}
onPick={(selected) => void handleFileChange(selected)}
/>
<label htmlFor="social-share-display-name" className="flex flex-col gap-1.5 text-sm font-medium text-[var(--theme-card-text-color)]">
Your name
<input
id="social-share-display-name"
type="text"
autoComplete="name"
required
maxLength={80}
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="h-11 w-full rounded-lg border border-[var(--theme-card-text-color)]/20 bg-[var(--theme-card-color)] px-3 text-sm font-normal outline-none focus:border-[var(--theme-button-color)]"
/>
</label>
<button
type="button"
disabled={!file || submitting}
disabled={!file || !displayName.trim() || displayName.trim().length > 80 || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white disabled:opacity-55"
>

View File

@ -0,0 +1,791 @@
import { useCallback, useEffect, useState } from "react";
import {
Calendar,
ImageOff,
Menu,
Radio,
RefreshCw,
X,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { formatEventScheduleRange } from "../lib/sessionDisplay";
import { EventBuzzLogo } from "../components/EventBuzzLogo";
import {
getSpotlightPresent,
type SpotlightPresentData,
} from "../lib/api";
const POLL_MS = 4000;
const SOURCE_LABELS = {
socialShare: "Social Share",
aiAvatar: "AI Avatar",
quiz: "Quiz",
photoWall: "Photo Wall",
} as const;
type SourceKey = keyof typeof SOURCE_LABELS;
export function SpotlightPresentPage() {
const { spotlightToken } = useParams<{ spotlightToken: string }>();
const [data, setData] = useState<SpotlightPresentData | null>(null);
const [selectedSource, setSelectedSource] = useState("all");
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const refresh = useCallback(async () => {
if (!spotlightToken) return;
try {
setIsRefreshing(true);
const next = await getSpotlightPresent(
spotlightToken,
selectedSource,
);
setData(next);
setErr(null);
} catch (error) {
/*
* Important:
* Do NOT clear existing data when one polling request fails.
* The last successful gallery should remain visible.
*/
setErr(
error instanceof Error
? error.message
: "Unable to refresh Spotlight",
);
} finally {
setLoading(false);
setIsRefreshing(false);
}
}, [spotlightToken, selectedSource]);
useEffect(() => {
void refresh();
const id = window.setInterval(() => {
void refresh();
}, POLL_MS);
return () => window.clearInterval(id);
}, [refresh]);
/*
* Initial loading screen
*/
if (loading && !data) {
return <SpotlightLoading />;
}
/*
* No data at all
*/
if (!data) {
return (
<div className="eb-gradient-auth flex min-h-dvh items-center justify-center px-6 text-center text-white">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-black/20 p-8 shadow-2xl backdrop-blur-xl">
<div className="mx-auto grid size-14 place-items-center rounded-full bg-white/10">
<Radio size={25} className="text-[var(--theme-button-color)]" />
</div>
<h1 className="mt-5 text-xl font-semibold">
Spotlight unavailable
</h1>
<p className="mt-2 text-sm text-white/65">
{err || "Unable to load the Spotlight gallery."}
</p>
<button
type="button"
onClick={() => void refresh()}
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-[var(--theme-button-color)] px-4 py-2.5 text-sm font-medium text-[var(--theme-button-text-color)] transition hover:opacity-90"
>
<RefreshCw size={15} />
Try again
</button>
</div>
</div>
);
}
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 (
<div className="eb-gradient-auth min-h-dvh bg-[var(--theme-bg-color)] text-[var(--theme-card-text-color)]">
{/* Existing event header */}
<SpotlightEventHeader
data={data}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
sourceFilters={sourceFilters}
selectedSource={selectedSource}
setSelectedSource={setSelectedSource}
/>
{/* Spotlight Hero */}
<section className="mx-auto w-full max-w-7xl px-4 pb-2 pt-3 sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-[var(--theme-card-color)] shadow-xl">
<div className="relative px-5 py-3.5 sm:px-6 sm:py-4">
{/* subtle background decoration */}
<div
className="pointer-events-none absolute -right-20 -top-20 size-52 rounded-full opacity-20 blur-3xl"
style={{
background:
"var(--theme-primary-color)",
}}
/>
<div className="relative flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="grid size-10 shrink-0 place-items-center rounded-lg bg-[var(--theme-button-color)]/12 text-[var(--theme-button-color)]">
<Radio size={19} strokeWidth={2} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">
Spotlight
</h1>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-emerald-500">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-500" />
Live
</span>
</div>
<p className="mt-1 text-sm text-[var(--theme-card-text-color)]/60">
Live event moments, shared in real time
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="rounded-lg bg-black/5 px-3 py-1.5 text-center">
<div className="text-xl font-bold leading-none">
{photos.length}
</div>
<div className="mt-1 text-[10px] font-medium uppercase tracking-wider opacity-55">
{photos.length === 1 ? "Image" : "Images"}
</div>
</div>
<div
className={`transition-opacity ${
isRefreshing
? "opacity-100"
: "opacity-0"
}`}
aria-hidden={!isRefreshing}
>
<RefreshCw
size={16}
className="animate-spin opacity-50"
/>
</div>
</div>
</div>
</div>
</div>
{/* Temporary connection warning */}
{err ? (
<div className="mt-3 flex items-center justify-center gap-2 text-xs text-[var(--theme-card-text-color)]/50">
<span className="size-1.5 rounded-full bg-amber-400" />
Reconnecting to Spotlight…
</div>
) : null}
</section>
{/* Moving Spotlight Gallery */}
{photos.length === 0 ? (
<SpotlightEmptyState selectedSource={selectedSource} />
) : (
<MovingSpotlightGallery
photos={photos}
showName={Boolean(data.showUploaderNameOnWall)}
/>
)}
</div>
);
}
function SpotlightEventHeader({
data,
menuOpen,
setMenuOpen,
sourceFilters,
selectedSource,
setSelectedSource,
}: {
data: SpotlightPresentData;
menuOpen: boolean;
setMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
sourceFilters: { value: string; label: string }[];
selectedSource: string;
setSelectedSource: React.Dispatch<React.SetStateAction<string>>;
}) {
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 (
<header className="relative z-50 flex min-h-[56px] w-full items-center border-b border-black/10 bg-[var(--theme-header-color)] px-4 py-2 shadow-sm sm:px-6">
{/* Left: Logo */}
<div className="flex min-w-0 flex-1 items-center">
{logoSrc ? (
<img
src={logoSrc}
alt={eventName}
className="max-h-9 max-w-[180px] object-contain object-left"
/>
) : (
<EventBuzzLogo
variant="color"
className="h-8 w-auto"
/>
)}
</div>
{/* Center: Event information */}
<div className="hidden min-w-0 flex-1 items-center justify-center gap-3 text-center sm:flex">
{eventType ? (
<span className="rounded-md bg-[var(--theme-button-color)]/15 px-2 py-1 text-[11px] font-medium">
{eventType}
</span>
) : null}
<h1 className="max-w-[360px] truncate text-base font-semibold">
{eventName}
</h1>
{schedule ? (
<div className="flex shrink-0 items-center gap-1.5 text-xs opacity-65">
<Calendar
className="size-3.5"
strokeWidth={2}
/>
<span>{schedule}</span>
</div>
) : null}
</div>
{/* Right: Menu */}
<div className="flex flex-1 justify-end">
<button
type="button"
onClick={() =>
setMenuOpen((open) => !open)
}
aria-expanded={menuOpen}
aria-controls="spotlight-menu"
aria-label={
menuOpen
? "Close Spotlight menu"
: "Open Spotlight menu"
}
className="inline-flex items-center gap-2 rounded-lg border border-black/15 bg-[var(--theme-card-color)] px-3 py-2 text-sm font-medium shadow-sm transition hover:bg-black/5"
>
{menuOpen ? (
<X size={18} />
) : (
<Menu size={18} />
)}
<span>Menu</span>
</button>
</div>
{/* Menu */}
{menuOpen ? (
<>
<button
type="button"
aria-label="Close Spotlight menu"
onClick={closeMenu}
className="fixed inset-0 z-40 cursor-default bg-black/20"
/>
<div
id="spotlight-menu"
className="absolute right-4 top-[calc(100%+8px)] z-50 w-[230px] overflow-hidden rounded-xl border border-black/10 bg-[var(--theme-card-color)] p-2 shadow-2xl sm:right-6"
role="dialog"
aria-label="Spotlight filters"
>
<div className="px-3 pb-2 pt-2">
<p className="text-xs font-semibold uppercase tracking-wider opacity-50">
Spotlight Filters
</p>
</div>
<div className="space-y-1">
{sourceFilters.map((filter) => {
const active =
selectedSource === filter.value;
return (
<button
key={filter.value}
type="button"
onClick={() =>
selectSource(filter.value)
}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left text-sm transition ${
active
? "bg-[var(--theme-button-color)] text-[var(--theme-button-text-color)]"
: "hover:bg-black/5"
}`}
>
<span>{filter.label}</span>
{active ? (
<span className="text-xs">
✓
</span>
) : null}
</button>
);
})}
</div>
</div>
</>
) : null}
</header>
);
}
/* -------------------------------------------------------
Loading
------------------------------------------------------- */
function SpotlightLoading() {
return (
<div className="eb-gradient-auth min-h-dvh bg-[var(--theme-bg-color)]">
<div className="mx-auto w-full max-w-7xl px-4 pt-5 sm:px-6 lg:px-8">
<div className="h-36 animate-pulse rounded-2xl bg-black/5" />
<div className="mt-4 flex gap-2">
<div className="h-9 w-20 animate-pulse rounded-full bg-black/5" />
<div className="h-9 w-28 animate-pulse rounded-full bg-black/5" />
<div className="h-9 w-24 animate-pulse rounded-full bg-black/5" />
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="aspect-square animate-pulse rounded-xl bg-black/5"
/>
))}
</div>
</div>
</div>
);
}
/* -------------------------------------------------------
Empty state
------------------------------------------------------- */
function SpotlightEmptyState({
selectedSource,
}: {
selectedSource: string;
}) {
const label =
selectedSource === "all"
? "Spotlight"
: SOURCE_LABELS[
selectedSource as keyof typeof SOURCE_LABELS
] || "this source";
return (
<div className="mx-auto flex min-h-[42dvh] max-w-xl flex-col items-center justify-center px-6 pb-16 text-center">
<div className="grid size-16 place-items-center rounded-2xl bg-black/5">
<ImageOff
size={30}
strokeWidth={1.5}
className="opacity-45"
/>
</div>
<h2 className="mt-5 text-lg font-semibold">
No images yet
</h2>
<p className="mt-1.5 max-w-sm text-sm opacity-55">
There are no images in {label} yet. New moments
will appear here automatically.
</p>
</div>
);
}
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 (
<main className="w-full overflow-hidden pb-10 pt-4">
<div className="flex flex-col gap-5 sm:gap-7">
{rows.map((rowPhotos, rowIndex) => (
<MovingSpotlightRow
key={rowIndex}
photos={rowPhotos}
reverse={rowIndex % 2 === 1}
showName={showName}
rowIndex={rowIndex}
/>
))}
</div>
</main>
);
}
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 (
<div
className="spotlight-marquee group relative w-full overflow-hidden"
style={
{
"--marquee-duration": `${28 + rowIndex * 6}s`,
} as React.CSSProperties
}
>
<div
className={`spotlight-marquee-track flex w-max items-start gap-5 sm:gap-7 ${
reverse
? "spotlight-marquee-reverse"
: "spotlight-marquee-forward"
}`}
>
{duplicatedPhotos.map((photo, index) => (
<MovingSpotlightTile
key={`${photo._id}:${photo.url}:${index}`}
photo={photo}
showName={showName}
/>
))}
</div>
</div>
);
}
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 (
<figure className="w-[150px] shrink-0 sm:w-[190px] md:w-[220px]">
<div className="relative aspect-square w-full overflow-hidden rounded-full border-[5px] border-[var(--theme-card-color)] bg-black/5 shadow-lg">
{!failed ? (
<img
src={src}
alt={
photo.displayName ||
sourceLabel
}
loading="lazy"
decoding="async"
onError={handleImageError}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black/5">
<ImageOff
size={30}
strokeWidth={1.5}
className="opacity-40"
/>
</div>
)}
</div>
{showName && photo.displayName ? (
<figcaption className="mt-2 truncate px-2 text-center text-sm font-semibold text-[var(--theme-card-text-color)]">
{photo.displayName}
</figcaption>
) : (
<figcaption className="mt-2 truncate px-2 text-center text-xs text-[var(--theme-card-text-color)]/55">
{sourceLabel}
</figcaption>
)}
</figure>
);
}
/* -------------------------------------------------------
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 (
<figure className="group m-0 min-w-0 overflow-hidden rounded-xl border border-black/8 bg-[var(--theme-card-color)] shadow-sm transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg">
<div
className="relative mx-auto w-full overflow-hidden bg-black/5"
style={{
maxWidth,
maxHeight,
aspectRatio,
borderRadius,
}}
>
{!failed ? (
<>
{!loaded ? (
<div
className="absolute inset-0 animate-pulse bg-black/5"
aria-hidden="true"
/>
) : null}
<img
src={src}
alt={
photo.displayName ||
sourceLabel
}
loading="lazy"
decoding="async"
onLoad={() => setLoaded(true)}
onError={handleImageError}
className="relative z-10 h-full w-full object-contain transition-transform duration-500 group-hover:scale-[1.02]"
/>
</>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 px-3 text-center opacity-50">
<ImageOff
size={28}
strokeWidth={1.5}
/>
<span className="text-xs">
Image unavailable
</span>
</div>
)}
</div>
<figcaption className="flex min-h-9 items-center justify-between gap-2 border-t border-black/5 px-3 py-2 text-xs">
<span className="min-w-0 truncate opacity-65">
{showName && photo.displayName
? photo.displayName
: sourceLabel}
</span>
<span className="shrink-0 rounded-full bg-black/5 px-2 py-1 text-[10px] font-medium opacity-65">
{sourceLabel}
</span>
</figcaption>
</figure>
);
}