Add spotlight photo feature
This commit is contained in:
parent
5dc0d49efa
commit
e36e4b94e4
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue