Add spotlight feature

This commit is contained in:
Tanuja Pansare 2026-09-30 18:01:53 +05:30
parent 2c86688b2d
commit ff8dfb0636
6 changed files with 465 additions and 38 deletions

View File

@ -14,6 +14,7 @@ import { SuperAdminMicrositeV2TemplatesPage } from "./views/SuperAdminMicrositeV
import { EventTicketCheckoutView } from "../views/EventTicketCheckoutView";
import { PublicFormView } from "../views/PublicFormView";
import { PublicAttendanceCheckInView } from "../views/PublicAttendanceCheckInView";
import { AppNavProvider } from "./contexts/AppNavContext";
export default function App() {
return (
@ -22,7 +23,9 @@ export default function App() {
<AuthProvider>
<PermissionProvider>
<BrowserRouter>
<AppNavProvider>
<GlobalMutationLoadingOverlay />
<Routes>
<Route path="/login" element={<LoginView />} />
<Route path="/register" element={<RegisterView />} />
@ -30,19 +33,34 @@ export default function App() {
<Route path="/event/:eventId/tickets" element={<EventTicketCheckoutView />} />
<Route path="/form/:formId" element={<PublicFormView />} />
<Route path="/attendance/:token" element={<PublicAttendanceCheckInView />} />
<Route path="/" element={<ProtectedLayout />}>
<Route index element={<Navigate to="/home" replace />} />
<Route path="super-admin" element={<PlatformAdminLayout />}>
<Route index element={<Navigate to="clients" replace />} />
<Route path="clients" element={<SuperAdminClientsPage />} />
<Route path="plans" element={<SuperAdminPlansPage />} />
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
<Route
path="microsite-templates"
element={<SuperAdminMicrositeV2TemplatesPage />}
/>
</Route>
<Route path=":eventId/searchphoto" element={<ProtectedLayout.EventPhotoSearchShell />} />
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
<Route
path=":eventId/searchphoto"
element={<ProtectedLayout.EventPhotoSearchShell />}
/>
<Route
path=":navId"
element={<ProtectedLayout.AppShellWithNav />}
/>
</Route>
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
</AppNavProvider>
</BrowserRouter>
</PermissionProvider>
</AuthProvider>

View File

@ -21,16 +21,19 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
import { SpotlightManagePanel } from "./SpotlightManagePanel";
import { SocialShareManagePanel } from "./SocialShareManagePanel";
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
import { EngagementManagePanel } from "./EngagementManagePanel";
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
import { photoWallApi } from "../../../services/api/photoWall";
import { spotlightApi } from "../../../services/api/spotlight";
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
import { socialShareApi } from "../../../services/api/socialShare";
import { aiAvatarApi } from "../../../services/api/aiAvatar";
import { engagementApi } from "../../../services/api/engagement";
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
import { spotlightPresentUrl } from "../../utils/spotlightUrls";
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
@ -321,6 +324,12 @@ export function EventHomeModules() {
const [photoWallUploadToken, setPhotoWallUploadToken] = useState<string | null>(null);
const [photoWallPresentToken, setPhotoWallPresentToken] = useState<string | null>(null);
const [copiedPhotoWallLink, setCopiedPhotoWallLink] = useState<"upload" | "present" | null>(null);
const [showSpotlightPanel, setShowSpotlightPanel] = useState(false);
const [spotlightEnabled, setSpotlightEnabled] = useState(false);
const [spotlightLoading, setSpotlightLoading] = useState(false);
const [spotlightCount, setSpotlightCount] = useState(0);
const [spotlightPresentToken, setSpotlightPresentToken] = useState<string | null>(null);
const [copiedSpotlightLink, setCopiedSpotlightLink] = useState(false);
const [showSocialSharePanel, setShowSocialSharePanel] = useState(false);
const [socialShareEnabled, setSocialShareEnabled] = useState(false);
const [socialShareLoading, setSocialShareLoading] = useState(false);
@ -691,6 +700,33 @@ export function EventHomeModules() {
void loadPhotoWallMeta();
}, [loadPhotoWallMeta]);
const loadSpotlightMeta = useCallback(async () => {
if (!selectedEventId || !canReadPhotoWall) {
setSpotlightEnabled(false);
setSpotlightCount(0);
setSpotlightPresentToken(null);
setSpotlightLoading(false);
return;
}
setSpotlightLoading(true);
try {
const res = await spotlightApi.get(selectedEventId);
setSpotlightEnabled(Boolean(res.data?.enabled));
setSpotlightCount(res.data?.photoCount ?? 0);
setSpotlightPresentToken(res.data?.presentToken ?? null);
} catch {
setSpotlightEnabled(false);
setSpotlightCount(0);
setSpotlightPresentToken(null);
} finally {
setSpotlightLoading(false);
}
}, [selectedEventId, canReadPhotoWall]);
useEffect(() => {
void loadSpotlightMeta();
}, [loadSpotlightMeta]);
const loadSocialShareMeta = useCallback(async () => {
if (!selectedEventId || !canReadSocialShare) {
setSocialShareEnabled(false);
@ -1070,14 +1106,57 @@ export function EventHomeModules() {
}
};
const copyPhotoWallLink = async (kind: "upload" | "present") => {
const handleSpotlightToggle = async (enabled: boolean) => {
if (!selectedEventId || !canWritePhotoWall) return;
setSpotlightLoading(true);
try {
const res = await spotlightApi.update(selectedEventId, { enabled });
if (!res.error && res.data) {
setSpotlightEnabled(Boolean(res.data.enabled));
setSpotlightCount(res.data.photoCount ?? 0);
setSpotlightPresentToken(res.data.presentToken ?? null);
}
} finally {
setSpotlightLoading(false);
}
};
const copySpotlightLink = async () => {
if (!spotlightPresentToken) return;
try {
await navigator.clipboard.writeText(
spotlightPresentUrl(spotlightPresentToken)
);
setCopiedSpotlightLink(true);
window.setTimeout(() => setCopiedSpotlightLink(false), 1500);
} catch {
/* ignore */
}
};
const spotlightMeta = !selectedEventId
? "Select an event to configure Spotlight"
: spotlightLoading
? "Loading…"
: spotlightEnabled
? `Enabled · ${spotlightCount} photo${spotlightCount === 1 ? "" : "s"}`
: "Disabled";
const copyPhotoWallLink = async (kind: "upload" | "present") => {
const url =
kind === "upload" && photoWallUploadToken
? photoWallUploadUrl(photoWallUploadToken)
: kind === "present" && photoWallPresentToken
? photoWallPresentUrl(photoWallPresentToken)
: "";
if (!url) return;
try {
await navigator.clipboard.writeText(url);
setCopiedPhotoWallLink(kind);
@ -1085,7 +1164,7 @@ export function EventHomeModules() {
} catch {
/* ignore */
}
};
};
const handleSavePollQuiz = async (
kind: "poll" | "quiz",
@ -1952,6 +2031,29 @@ export function EventHomeModules() {
}
/>
) : null}
{canReadPhotoWall ? (
<ModuleCard
icon={<Image size={18} />}
title="Spotlight"
meta={spotlightMeta}
description="One presenter wall for published event photos"
headerAction={spotlightEnabled && spotlightPresentToken ? (
<button type="button" title="Copy Spotlight presenter link" onClick={() => void copySpotlightLink()} style={formHeaderIconButtonStyle()}>
{copiedSpotlightLink ? <Check size={14} /> : <Share2 size={14} />}
</button>
) : null}
actions={
<>
{spotlightEnabled ? (
<button type="button" disabled={actionsDisabled || spotlightLoading || !canWritePhotoWall} style={{ ...moduleButtonStyle(), opacity: actionsDisabled || spotlightLoading || !canWritePhotoWall ? 0.55 : 1 }} onClick={() => void handleSpotlightToggle(false)}>Disable</button>
) : (
<button type="button" disabled={actionsDisabled || spotlightLoading || !canWritePhotoWall} style={{ ...moduleButtonStyle(true), opacity: actionsDisabled || spotlightLoading || !canWritePhotoWall ? 0.55 : 1 }} onClick={() => void handleSpotlightToggle(true)}>Enable</button>
)}
<button type="button" disabled={actionsDisabled} style={{ ...moduleButtonStyle(spotlightEnabled), opacity: actionsDisabled ? 0.55 : 1 }} onClick={() => setShowSpotlightPanel(true)}>Manage</button>
</>
}
/>
) : null}
{canReadWaNotifications ? (
<ModuleCard
icon={<MessageSquare size={18} />}
@ -2221,6 +2323,16 @@ export function EventHomeModules() {
/>
) : null}
{showSpotlightPanel && selectedEventId ? (
<SpotlightManagePanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
canWrite={canWritePhotoWall}
onClose={() => setShowSpotlightPanel(false)}
onChanged={() => void loadSpotlightMeta()}
/>
) : null}
{showSocialSharePanel && selectedEventId ? (
<SocialShareManagePanel
eventId={selectedEventId}

View File

@ -0,0 +1,221 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Check, Copy, Image, Link2, Plus, Trash2, Type } from 'lucide-react';
import { PanelShell, Field, Label, IS, SS, HelperText } from '../panels/PanelShell';
import { spotlightApi, type SpotlightConfig, type SpotlightPhoto, type SpotlightSource, type SpotlightSources } from '../../../services/api/spotlight';
import { uploadMediaFile } from '../../../services/api/media';
import { spotlightPresentUrl } from '../../utils/spotlightUrls';
import { PhotoWallFrameDesigner } from './PhotoWallFrameDesigner';
import { PhotoWallFrameInspector } from './PhotoWallFrameInspector';
import {
defaultCustomTextElement,
defaultFrameImageLayout,
defaultNameElement,
defaultPhotoElement,
normalizeFrameImageLayout,
normalizeFrameLayout,
type PhotoWallFrameElement,
type PhotoWallFrameImageLayout,
} from './photoWallFrameLayout';
type Tab = 'photos' | 'settings' | 'frame' | 'links';
const SOURCE_LABELS: Record<SpotlightSource, string> = {
socialShare: 'Social Share', aiAvatar: 'AI Avatar', quiz: 'Quiz', photoWall: 'Photo Wall',
};
const DEFAULT_SOURCES: SpotlightSources = { socialShare: true, aiAvatar: true, quiz: true, photoWall: true };
export function SpotlightManagePanel({
eventId, eventName, canWrite, onClose, onChanged,
}: {
eventId: string; eventName?: string; canWrite: boolean; onClose: () => void; onChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>('photos');
const [config, setConfig] = useState<SpotlightConfig | null>(null);
const [photos, setPhotos] = useState<SpotlightPhoto[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState({
sources: DEFAULT_SOURCES,
photosPerRow: 4,
totalRows: 3,
marqueeDirection: 'alternate' as SpotlightConfig['marqueeDirection'],
maxPhotoWidth: 320,
maxPhotoHeight: 320,
gapPx: 8,
presentMarginTopPx: 0,
presentMarginLeftPx: 0,
presentMarginRightPx: 0,
presentPhotoShape: 'rounded' as SpotlightConfig['presentPhotoShape'],
showUploaderNameOnWall: false,
frameEnabled: false,
frameImageUrl: '',
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
frameCanvasWidth: 1080,
frameCanvasHeight: 1080,
frameLayout: [] as PhotoWallFrameElement[],
});
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await spotlightApi.get(eventId);
if (res.error || !res.data) throw new Error(res.error || 'Could not load Spotlight.');
const next = res.data;
setConfig(next);
setPhotos(next.photos || []);
setDraft({
sources: { ...DEFAULT_SOURCES, ...next.sources },
photosPerRow: next.photosPerRow ?? 4,
totalRows: next.totalRows ?? 3,
marqueeDirection: next.marqueeDirection || 'alternate',
maxPhotoWidth: next.maxPhotoWidth ?? 320,
maxPhotoHeight: next.maxPhotoHeight ?? 320,
gapPx: next.gapPx ?? 8,
presentMarginTopPx: next.presentMarginTopPx ?? 0,
presentMarginLeftPx: next.presentMarginLeftPx ?? 0,
presentMarginRightPx: next.presentMarginRightPx ?? 0,
presentPhotoShape: next.presentPhotoShape || 'rounded',
showUploaderNameOnWall: Boolean(next.showUploaderNameOnWall),
frameEnabled: Boolean(next.frameEnabled),
frameImageUrl: next.frameImageUrl || '',
frameImageLayout: normalizeFrameImageLayout(next.frameImageLayout),
frameCanvasWidth: next.frameCanvasWidth ?? 1080,
frameCanvasHeight: next.frameCanvasHeight ?? 1080,
frameLayout: normalizeFrameLayout(next.frameLayout, next.frameCanvasWidth ?? 1080, next.frameCanvasHeight ?? 1080),
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load Spotlight.');
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => { void load(); }, [load]);
const save = async () => {
if (!canWrite) return;
setSaving(true);
setError('');
try {
const payload = tab === 'frame'
? {
frameEnabled: draft.frameEnabled, frameImageUrl: draft.frameImageUrl.trim() || null,
frameImageLayout: draft.frameImageLayout, frameCanvasWidth: draft.frameCanvasWidth,
frameCanvasHeight: draft.frameCanvasHeight, frameLayout: draft.frameLayout,
}
: {
sources: draft.sources, photosPerRow: draft.photosPerRow, totalRows: draft.totalRows,
marqueeDirection: draft.marqueeDirection, maxPhotoWidth: draft.maxPhotoWidth,
maxPhotoHeight: draft.maxPhotoHeight, gapPx: draft.gapPx,
presentMarginTopPx: draft.presentMarginTopPx, presentMarginLeftPx: draft.presentMarginLeftPx,
presentMarginRightPx: draft.presentMarginRightPx, presentPhotoShape: draft.presentPhotoShape,
showUploaderNameOnWall: draft.showUploaderNameOnWall,
};
const res = await spotlightApi.update(eventId, payload);
if (res.error || !res.data) throw new Error(res.error || 'Could not save Spotlight.');
setConfig(res.data);
setPhotos(res.data.photos || []);
onChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not save Spotlight.');
} finally {
setSaving(false);
}
};
const copyLink = async () => {
if (!config?.presentToken) return;
try {
await navigator.clipboard.writeText(spotlightPresentUrl(config.presentToken));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { setError('Could not copy presenter link.'); }
};
const uploadFrame = async (file: File | null) => {
if (!file || !canWrite) return;
setUploading(true);
try {
const result = await uploadMediaFile(file, { eventId, module: 'spotlight/frames' });
if (!result.url) throw new Error('Upload succeeded but no URL was returned.');
setDraft((current) => ({ ...current, frameImageUrl: result.url, frameImageLayout: defaultFrameImageLayout() }));
} catch (e) { setError(e instanceof Error ? e.message : 'Frame upload failed.'); }
finally { setUploading(false); }
};
const addLayer = (element: PhotoWallFrameElement) => {
setDraft((current) => ({ ...current, frameLayout: [...current.frameLayout, element] }));
setSelectedId(element.id);
};
const patchSelected = (patch: Partial<PhotoWallFrameElement>) => {
setDraft((current) => ({
...current,
frameLayout: current.frameLayout.map((element) => element.id === selectedId ? { ...element, ...patch } : element),
}));
};
const tabButton = (id: Tab, label: string) => (
<button key={id} type="button" onClick={() => setTab(id)} style={{ flex: 1, padding: '8px 10px', border: 0, borderBottom: tab === id ? '2px solid var(--primary)' : '2px solid transparent', background: 'transparent', color: tab === id ? 'var(--primary)' : 'var(--muted)', cursor: 'pointer' }}>{label}</button>
);
return (
<PanelShell onClose={onClose} title="Spotlight" breadcrumb={eventName ? `Event Home · ${eventName}` : 'Event Home'} description="Combine published event images on one presenter wall" completedCount={0} totalSections={0} ctaLabel={tab === 'frame' ? 'Save frame' : 'Save settings'} showProgress={false} hidePrimaryCta={tab === 'photos' || tab === 'links'} ctaDisabled={!canWrite || saving || loading} onCtaClick={() => void save()} panelWidth={tab === 'frame' ? 'min(960px,100vw)' : 'min(720px,100vw)'}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', padding: '0 12px', flexWrap: 'wrap' }}>
{tabButton('photos', 'Photos')}{tabButton('settings', 'Settings')}{tabButton('frame', 'Frame')}{tabButton('links', 'Links')}
</div>
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{error ? <p style={{ margin: 0, color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{error}</p> : null}
{loading ? <p style={{ margin: 0, color: 'var(--muted)' }}>Loading…</p> : null}
{!loading && tab === 'photos' ? (
photos.length ? <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(130px,1fr))', gap: 10 }}>
{photos.map((photo) => <figure key={photo._id} style={{ margin: 0, border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}><img src={photo.url} alt="" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} /><figcaption style={{ padding: 8, fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>{SOURCE_LABELS[photo.source]}</figcaption></figure>)}
</div> : <p style={{ margin: 0, color: 'var(--muted)' }}>No published photos from the selected sources yet.</p>
) : null}
{!loading && tab === 'settings' ? <>
<Field><Label text="Image sources" /><HelperText text="Only enabled source modules and published images are included." />
<label style={{ display: 'flex', alignItems: 'center', gap: 8, padding: 10, marginBottom: 8, border: '1px solid var(--border)', borderRadius: 6, fontWeight: 600 }}><input type="checkbox" disabled={!canWrite} checked={Object.values(draft.sources).every(Boolean)} onChange={(e) => setDraft((current) => ({ ...current, sources: { socialShare: e.target.checked, aiAvatar: e.target.checked, quiz: e.target.checked, photoWall: e.target.checked } }))} />All sources</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,minmax(0,1fr))', gap: 8 }}>
{(Object.keys(SOURCE_LABELS) as SpotlightSource[]).map((source) => <label key={source} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: 10, border: '1px solid var(--border)', borderRadius: 6 }}><input type="checkbox" disabled={!canWrite} checked={draft.sources[source]} onChange={(e) => setDraft((current) => ({ ...current, sources: { ...current.sources, [source]: e.target.checked } }))} />{SOURCE_LABELS[source]}</label>)}
</div>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,minmax(0,1fr))', gap: 10 }}>
<Field><Label text="Photos per row" /><input type="number" min={1} max={12} disabled={!canWrite} value={draft.photosPerRow} onChange={(e) => setDraft((d) => ({ ...d, photosPerRow: Number(e.target.value) || 4 }))} style={IS} /></Field>
<Field><Label text="Rows" /><input type="number" min={1} max={8} disabled={!canWrite} value={draft.totalRows} onChange={(e) => setDraft((d) => ({ ...d, totalRows: Number(e.target.value) || 3 }))} style={IS} /></Field>
<Field><Label text="Marquee direction" /><select disabled={!canWrite} value={draft.marqueeDirection} onChange={(e) => setDraft((d) => ({ ...d, marqueeDirection: e.target.value as SpotlightConfig['marqueeDirection'] }))} style={SS}><option value="alternate">Alternate rows</option><option value="left">Left</option><option value="right">Right</option></select></Field>
<Field><Label text="Photo shape" /><select disabled={!canWrite} value={draft.presentPhotoShape} onChange={(e) => setDraft((d) => ({ ...d, presentPhotoShape: e.target.value as SpotlightConfig['presentPhotoShape'] }))} style={SS}><option value="rounded">Rounded</option><option value="rect">Rectangle</option><option value="circle">Circle</option></select></Field>
<Field><Label text="Photo width (px)" /><input type="number" min={80} max={1200} disabled={!canWrite} value={draft.maxPhotoWidth} onChange={(e) => setDraft((d) => ({ ...d, maxPhotoWidth: Number(e.target.value) || 320 }))} style={IS} /></Field>
<Field><Label text="Photo height (px)" /><input type="number" min={80} max={1200} disabled={!canWrite} value={draft.maxPhotoHeight} onChange={(e) => setDraft((d) => ({ ...d, maxPhotoHeight: Number(e.target.value) || 320 }))} style={IS} /></Field>
<Field><Label text="Gap (px)" /><input type="number" min={0} max={64} disabled={!canWrite} value={draft.gapPx} onChange={(e) => setDraft((d) => ({ ...d, gapPx: Number(e.target.value) || 0 }))} style={IS} /></Field>
<Field><Label text="Top margin (px)" /><input type="number" min={0} max={2000} disabled={!canWrite} value={draft.presentMarginTopPx} onChange={(e) => setDraft((d) => ({ ...d, presentMarginTopPx: Number(e.target.value) || 0 }))} style={IS} /></Field>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" disabled={!canWrite} checked={draft.showUploaderNameOnWall} onChange={(e) => setDraft((d) => ({ ...d, showUploaderNameOnWall: e.target.checked }))} />Show contributor names when available</label>
</> : null}
{!loading && tab === 'frame' ? <>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" disabled={!canWrite} checked={draft.frameEnabled} onChange={(e) => setDraft((d) => ({ ...d, frameEnabled: e.target.checked }))} />Enable digital frame</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ ...buttonStyle, cursor: canWrite && !uploading ? 'pointer' : 'not-allowed' }}><Image size={15} />{uploading ? 'Uploading…' : 'Upload frame'}<input hidden type="file" accept="image/*" disabled={!canWrite || uploading} onChange={(e) => void uploadFrame(e.target.files?.[0] || null)} /></label>
<button type="button" disabled={!canWrite} onClick={() => addLayer(defaultPhotoElement(draft.frameCanvasWidth, draft.frameCanvasHeight))} style={buttonStyle}><Plus size={15} />Photo slot</button>
<button type="button" disabled={!canWrite || draft.frameLayout.some((element) => element.field === 'name')} onClick={() => addLayer(defaultNameElement(draft.frameCanvasWidth, draft.frameCanvasHeight))} style={buttonStyle}><Plus size={15} />Name</button>
<button type="button" disabled={!canWrite} onClick={() => addLayer(defaultCustomTextElement('Spotlight', draft.frameCanvasWidth, draft.frameCanvasHeight))} style={buttonStyle}><Type size={15} />Text</button>
<button type="button" disabled={!canWrite || !selectedId} onClick={() => { setDraft((d) => ({ ...d, frameLayout: d.frameLayout.filter((element) => element.id !== selectedId) })); setSelectedId(null); }} style={buttonStyle}><Trash2 size={15} />Remove layer</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) minmax(220px,0.65fr)', gap: 16, alignItems: 'start' }}>
<PhotoWallFrameDesigner canvasWidth={draft.frameCanvasWidth} canvasHeight={draft.frameCanvasHeight} frameImageUrl={draft.frameImageUrl} frameImageLayout={draft.frameImageLayout} layoutElements={draft.frameLayout} selectedId={selectedId} onSelect={setSelectedId} onLayoutChange={(frameLayout) => setDraft((d) => ({ ...d, frameLayout }))} onFrameImageLayoutChange={(frameImageLayout) => setDraft((d) => ({ ...d, frameImageLayout }))} />
<PhotoWallFrameInspector element={draft.frameLayout.find((element) => element.id === selectedId) || null} onChange={patchSelected} disabled={!canWrite} canvasWidth={draft.frameCanvasWidth} canvasHeight={draft.frameCanvasHeight} />
</div>
</> : null}
{!loading && tab === 'links' ? <>
<Field><Label text="Presenter / projector URL" /><HelperText text="Open this link on the display. It refreshes as new published photos arrive." /><div style={{ display: 'flex', gap: 8 }}><input readOnly value={config?.presentToken ? spotlightPresentUrl(config.presentToken) : 'Enable Spotlight to generate a link'} style={{ ...IS, flex: 1 }} /><button type="button" disabled={!config?.presentToken} onClick={() => void copyLink()} style={buttonStyle}>{copied ? <Check size={15} /> : <Copy size={15} />}{copied ? 'Copied' : 'Copy'}</button></div></Field>
{canWrite ? <button type="button" onClick={() => void spotlightApi.update(eventId, { regenerateTokens: true }).then((res) => { if (res.data) { setConfig(res.data); onChanged?.(); } })} style={{ ...buttonStyle, alignSelf: 'flex-start' }}><Link2 size={15} />Regenerate link</button> : null}
</> : null}
</div>
</PanelShell>
);
}
const buttonStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 'var(--radius-button)', background: 'var(--card)', color: 'var(--foreground)', fontSize: 'var(--text-sm)', cursor: 'pointer' };

View File

@ -0,0 +1,20 @@
function eventbuzzPublicBase(): string {
const fromEnv =
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === 'string' && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
'';
const raw = (fromEnv || (import.meta.env.DEV ? 'http://localhost:5174' : 'https://join.eventbuzz.live')).replace(/\/+$/, '');
try {
const url = new URL(raw);
if (url.hostname === 'eventbuzz.live' || url.hostname === 'www.eventbuzz.live') {
url.hostname = 'join.eventbuzz.live';
return url.origin;
}
} catch {
/* Keep configured base URL as-is. */
}
return raw;
}
export function spotlightPresentUrl(presentToken: string): string {
return `${eventbuzzPublicBase()}/spotlight-present/${encodeURIComponent(presentToken)}`;
}

View File

@ -61,6 +61,7 @@ export {
type PhotoWallMarqueeDirection,
type PhotoWallEventHeader,
} from './photoWall';
export { spotlightApi, type SpotlightConfig, type SpotlightPhoto, type SpotlightSource, type SpotlightSources, type SpotlightUpdatePayload } from './spotlight';
export {
attendancesApi,

View File

@ -0,0 +1,55 @@
import { api } from './client';
import type { PhotoWallEventHeader, PhotoWallFrameElement, PhotoWallFrameImageLayout, PhotoWallMarqueeDirection, PhotoWallPresentPhotoShape } from './photoWall';
export type SpotlightSource = 'socialShare' | 'aiAvatar' | 'quiz' | 'photoWall';
export type SpotlightSources = Record<SpotlightSource, boolean>;
export interface SpotlightPhoto {
_id: string;
source: SpotlightSource;
url: string;
createdAt?: string;
displayName?: string | null;
}
export interface SpotlightConfig {
_id: string;
event: string;
organization: string;
enabled: boolean;
sources: SpotlightSources;
presentToken: string | null;
photoCount: number;
photos: SpotlightPhoto[];
photosPerRow: number;
totalRows: number;
marqueeDirection: PhotoWallMarqueeDirection;
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
presentMarginTopPx: number;
presentMarginLeftPx: number;
presentMarginRightPx: number;
presentPhotoShape: PhotoWallPresentPhotoShape;
showUploaderNameOnWall: boolean;
frameEnabled: boolean;
frameImageUrl: string | null;
frameImageLayout: PhotoWallFrameImageLayout;
frameCanvasWidth: number;
frameCanvasHeight: number;
frameLayout: PhotoWallFrameElement[];
presenterEventHeader: PhotoWallEventHeader;
}
export type SpotlightUpdatePayload = Partial<Omit<SpotlightConfig, '_id' | 'event' | 'organization' | 'photos' | 'photoCount'>> & {
regenerateTokens?: boolean;
};
export const spotlightApi = {
get(eventId: string) {
return api.get<SpotlightConfig>(`/events/${eventId}/spotlight`);
},
update(eventId: string, payload: SpotlightUpdatePayload) {
return api.patch<SpotlightConfig>(`/events/${eventId}/spotlight`, payload);
},
};