Add spotlight feature
This commit is contained in:
parent
2c86688b2d
commit
ff8dfb0636
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,6 +1106,47 @@ export function EventHomeModules() {
|
|||
}
|
||||
};
|
||||
|
||||
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
|
||||
|
|
@ -1077,7 +1154,9 @@ export function EventHomeModules() {
|
|||
: kind === "present" && photoWallPresentToken
|
||||
? photoWallPresentUrl(photoWallPresentToken)
|
||||
: "";
|
||||
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedPhotoWallLink(kind);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue