Add event photo search

This commit is contained in:
Tanuja Pansare 2026-08-04 17:05:02 +05:30
parent c803b9d253
commit 6af4fc1608
4 changed files with 683 additions and 4 deletions

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
@ -19,6 +19,7 @@ import { ShareFormPanel } from "../forms/ShareFormPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
@ -262,7 +263,8 @@ export function EventHomeModules() {
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
const [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
@ -1097,6 +1099,25 @@ export function EventHomeModules() {
}
/>
) : null}
<ModuleCard
icon={<Image size={18} />}
title="Event Photo Search"
description="Upload event photos and search by image"
actions={
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setShowEventPhotoSearchPanel(true)}
>
Manage
</button>
}
/>
{canReadEmailCampaigns ? (
<ModuleCard
icon={<Mail size={18} />}
@ -1235,7 +1256,7 @@ export function EventHomeModules() {
/>
) : null}
{showDocumentsPanel && selectedEventId ? (
{showDocumentsPanel && selectedEventId ? (
<EventDocumentsPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
@ -1243,7 +1264,16 @@ export function EventHomeModules() {
/>
) : null}
{showEmailCampaignsPanel && selectedEventId ? (
{showEventPhotoSearchPanel && selectedEventId ? (
<EventPhotoSearchPanel
open
eventId={selectedEventId}
eventName={eventName ?? undefined}
onClose={() => setShowEventPhotoSearchPanel(false)}
/>
) : null}
{showEmailCampaignsPanel && selectedEventId ? (
<EventEmailCampaignsPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}

View File

@ -0,0 +1,590 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Trash2, Image, Search } from 'lucide-react';
import { eventPhotoApi } from '../../../services/api';
import type { EventPhotoRow } from '../../../services/api/eventPhoto';
type Props = {
open: boolean;
eventId: string;
eventName?: string;
onClose: () => void;
};
function formatWhen(iso?: string) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return '—';
}
}
export function EventPhotoSearchPanel({ open, eventId, eventName, onClose }: Props) {
const [loadingPhotos, setLoadingPhotos] = useState(false);
const [photos, setPhotos] = useState<EventPhotoRow[]>([]);
const [err, setErr] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [queryFile, setQueryFile] = useState<File | null>(null);
const queryFileInputRef = useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = useState<EventPhotoRow[] | null>(null);
const [searching, setSearching] = useState(false);
const [deletedMsg, setDeletedMsg] = useState<string | null>(null);
const loadPhotos = async () => {
setLoadingPhotos(true);
setErr(null);
try {
const r = await eventPhotoApi.listEventPhotos(eventId);
setPhotos(r.data?.photos ?? []);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Failed to load photos');
setPhotos([]);
} finally {
setLoadingPhotos(false);
}
};
useEffect(() => {
if (!open) return;
void loadPhotos();
setSearchResults(null);
setQueryFile(null);
setFiles([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, eventId]);
const onUpload = async () => {
if (files.length === 0) return;
setUploading(true);
setErr(null);
try {
const res = await eventPhotoApi.uploadEventPhoto(eventId, files);
if (!res.ok) {
let msg = res.statusText;
try {
const j = await res.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
setFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
await loadPhotos();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploading(false);
}
};
const onDelete = async (photo: EventPhotoRow) => {
const ok = window.confirm(`Delete "${photo.originalFileName}"?`);
if (!ok) return;
setErr(null);
try {
const res = await eventPhotoApi.deleteEventPhoto(eventId, photo._id);
if (!res.ok) {
let msg = res.statusText;
try {
const j = await res.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
setDeletedMsg('Photo deleted.');
await loadPhotos();
setTimeout(() => setDeletedMsg(null), 1800);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Delete failed');
}
};
const onFindPhotos = async () => {
if (!queryFile) return;
setSearching(true);
setErr(null);
// For now, just show a placeholder — backend search integration to be added later.
// This simulates a search by showing all photos (no actual vector search yet).
try {
const r = await eventPhotoApi.searchEventPhotos(
eventId,
queryFile
);
if (!r.ok) {
throw new Error("Search failed");
}
const data = await r.json();
console.log(data);
setSearchResults(data.data.matches ?? []);
// setSearchResults(data.matches ?? []);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Search failed');
setSearchResults(null);
} finally {
setSearching(false);
}
};
if (!open) return null;
const displayPhotos = searchResults !== null ? searchResults : photos;
return (
<>
<div
onClick={onClose}
role="presentation"
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="event-photo-search-title"
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100%',
width: 'min(820px, 100vw)',
background: 'var(--card)',
zIndex: 201,
display: 'flex',
flexDirection: 'column',
boxShadow: '-4px 0 40px rgba(0,0,0,0.2)',
animation: 'eventPhotoSlideIn 0.2s ease',
}}
>
<style>{`@keyframes eventPhotoSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
padding: '16px 18px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)', color: 'var(--muted)', margin: '0 0 4px' }}>
Event Photo Search
</p>
<h2
id="event-photo-search-title"
style={{
fontFamily: 'var(--font-inter)',
fontSize: 'var(--text-lg)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--foreground)',
margin: 0,
lineHeight: 1.3,
wordBreak: 'break-word',
}}
>
{eventName || 'Photos'}
</h2>
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
Upload event photos
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
flexShrink: 0,
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--border)',
background: 'var(--card)',
color: 'var(--muted)',
cursor: 'pointer',
}}
>
<X size={18} />
</button>
</div>
{/* Upload Section */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: '1 1 280px' }}>
<label style={{ display: 'block', fontSize: 10, fontFamily: 'var(--font-noto-sans)', fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>
Upload Photo
</label>
{/* <input
type="text"
placeholder="Enter Document ID (docId)"
value={docId}
onChange={(e) => setDocId(e.target.value)}
style={{
width: '100%',
padding: '8px 10px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
color: 'var(--foreground)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-noto-sans)',
outline: 'none',
marginBottom: 8,
boxSizing: 'border-box',
}}
/> */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
padding: '10px 12px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
}}
>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
padding: '8px 14px',
border: '1px solid var(--primary)',
borderRadius: 'var(--radius-button)',
background: 'var(--primary)',
color: 'var(--primary-foreground)',
cursor: 'pointer',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
📁 Choose Photo
</button>
<span
style={{
color: files ? 'var(--foreground)' : 'var(--muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
fontSize: 'var(--text-sm)',
}}
>
{files.length === 0 ? (
"No files selected"
) : (
files.map(f => f.name).join(", ")
)}
</span>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
style={{ display: 'none' }}
onChange={(e) => {
const selected = Array.from(e.target.files || []);
setFiles(selected);
}}
/>
<p style={{ marginTop: 8, fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
Supported: JPEG, PNG, GIF, WEBP, BMP
</p>
</div>
<button
type="button"
disabled={files.length === 0 || uploading}
onClick={() => void onUpload()}
style={{
minWidth: 140,
height: 44,
padding: '0 20px',
alignSelf: 'flex-start',
marginTop: 30,
border: 'none',
borderRadius: 'var(--radius-button)',
background: !files || uploading ? '#cbd5e1' : 'var(--primary)',
color: '#fff',
fontWeight: 600,
cursor: !files || uploading ? 'not-allowed' : 'pointer',
transition: 'all .2s',
}}
>
{uploading ? 'Uploading...' : 'Upload Photo'}
</button>
</div>
</div>
{/* Find Photos Section */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0, background: 'color-mix(in srgb, var(--primary) 5%, transparent)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 300px' }}>
<Search size={18} style={{ color: 'var(--primary)' }} />
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--foreground)', fontFamily: 'var(--font-inter)' }}>
Find Photos
</span>
</div>
<input
ref={queryFileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0] || null;
setQueryFile(f);
if (f) {
// Auto-trigger search when file is selected
setSearchResults(null);
}
}}
/>
{!queryFile ? (
<button
type="button"
onClick={() => queryFileInputRef.current?.click()}
style={{
padding: '10px 18px',
border: '1px solid var(--primary)',
borderRadius: 'var(--radius-button)',
background: 'var(--primary)',
color: 'var(--primary-foreground)',
cursor: 'pointer',
fontWeight: 600,
fontSize: 'var(--text-sm)',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
<Search size={16} />
Find Photos
</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: '1 1 auto' }}>
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--foreground)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200 }}>
{queryFile.name}
</span>
<button
type="button"
onClick={() => void onFindPhotos()}
disabled={searching}
style={{
padding: '10px 18px',
border: 'none',
borderRadius: 'var(--radius-button)',
background: searching ? '#cbd5e1' : 'var(--primary)',
color: '#fff',
cursor: searching ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: 'var(--text-sm)',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
{searching ? 'Searching...' : 'Search'}
</button>
<button
type="button"
onClick={() => {
setQueryFile(null);
setSearchResults(null);
if (queryFileInputRef.current) queryFileInputRef.current.value = '';
}}
style={{
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
color: 'var(--muted)',
cursor: 'pointer',
}}
>
<X size={14} />
</button>
</div>
)}
</div>
{searchResults !== null && (
<p style={{ margin: '8px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
Showing {searchResults.length} result{searchResults.length === 1 ? '' : 's'}
<button
type="button"
onClick={() => {
setSearchResults(null);
setQueryFile(null);
if (queryFileInputRef.current) queryFileInputRef.current.value = '';
}}
style={{
marginLeft: 12,
padding: '2px 8px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
color: 'var(--muted)',
cursor: 'pointer',
fontSize: 'var(--text-xs)',
}}
>
Clear
</button>
</p>
)}
</div>
{/* Photo List */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 0 16px' }}>
{err ? (
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{err}</p>
) : null}
{deletedMsg ? (
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--success)', fontSize: 'var(--text-sm)' }}>{deletedMsg}</p>
) : null}
{loadingPhotos ? (
<p style={{ padding: '24px 18px', margin: 0, color: 'var(--muted)' }}>Loading…</p>
) : displayPhotos.length === 0 ? (
<div style={{ padding: '26px 18px' }}>
<div style={{ border: '2px dashed var(--border)', borderRadius: 'var(--radius-card)', padding: '22px 16px', textAlign: 'center' }}>
<Image size={32} style={{ margin: '0 auto 10px', color: 'var(--muted)' }} />
<p style={{ margin: 0, fontFamily: 'var(--font-inter)', fontSize: 'var(--text-base)', color: 'var(--foreground)', fontWeight: 700 }}>
No photos
</p>
<p style={{ margin: '8px 0 0', fontFamily: 'var(--font-noto-sans)', fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>
Upload the first photo for this event.
</p>
</div>
</div>
) : (
<div style={{ padding: '12px 18px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
{displayPhotos.map((p) => (
<div
key={p._id}
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--radius-card)',
overflow: 'hidden',
background: 'var(--card)',
position: 'relative',
}}
>
<div
style={{
width: '100%',
height: 140,
background: 'var(--secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{p.url ? (
<img
src={p.url}
alt={p.originalFileName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
) : (
<Image size={32} style={{ color: 'var(--muted)' }} />
)}
</div>
<div style={{ padding: '8px 10px' }}>
<p style={{ margin: 0, fontSize: 'var(--text-xs)', fontWeight: 600, color: 'var(--foreground)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.originalFileName}
</p>
<p style={{ margin: '4px 0 0', fontSize: 10, color: 'var(--muted)' }}>
Document ID: {p._id}
</p>
<p style={{ margin: '2px 0 0', fontSize: 10, color: 'var(--muted)' }}>
{formatWhen(p.uploadedAt)}
</p>
{p.embedding_generated && (
<span
style={{
display: 'inline-block',
marginTop: 4,
padding: '1px 6px',
borderRadius: 4,
background: '#dcfce7',
color: '#15803d',
fontSize: 9,
fontWeight: 600,
}}
>
Embedding Ready
</span>
)}
<div style={{ marginTop: 6, display: 'flex', gap: 4 }}>
<button
type="button"
onClick={() => window.open(p.url, '_blank', 'noopener,noreferrer')}
style={{
flex: 1,
padding: '4px 8px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--secondary)',
color: 'var(--foreground)',
cursor: 'pointer',
fontSize: 10,
}}
>
View
</button>
<button
type="button"
onClick={() => void onDelete(p)}
style={{
padding: '4px 8px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card)',
color: 'var(--destructive)',
cursor: 'pointer',
fontSize: 10,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Trash2 size={12} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,58 @@
import { api, fetchWithAuth, API_BASE_URL } from './client';
export interface EventPhotoRow {
_id: string;
event: string;
organization: string;
uploadedBy?: string | null;
fileName: string;
originalFileName: string;
url: string;
s3Key: string;
mimeType?: string;
size?: number;
embedding_generated: boolean;
uploadedAt?: string;
}
export const eventPhotoApi = {
listEventPhotos(eventId: string) {
const path = `/events/${eventId}/photos`;
return api.get<{ photos: EventPhotoRow[] }>(path);
},
uploadEventPhoto(eventId: string, files: File[]) {
const fd = new FormData();
files.forEach(file => {
fd.append("files", file, file.name);
});
return fetchWithAuth(`/events/${eventId}/photos`, {
method: "POST",
body: fd,
});
},
searchEventPhotos(eventId: string, file: File) {
const fd = new FormData();
fd.append("files", file);
return fetchWithAuth(`/events/${eventId}/photos/search`, {
method: "POST",
body: fd,
});
},
async deleteEventPhoto(eventId: string, photoId: string) {
return fetchWithAuth(`/events/${eventId}/photos/${photoId}`, {
method: 'DELETE',
});
},
};

View File

@ -12,6 +12,7 @@ export { eventsApi } from './events';
export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } from './rewardPoints';
export { participantsApi, type ParticipantWithEvent } from './participants';
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
export { eventPhotoApi, type EventPhotoRow } from './eventPhoto';
export {
attendancesApi,