Merge origin/main; keep exhibitors and event photo search modules.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
8e8f506f9a
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Link2, Mail, Plus, Share2, ShieldCheck, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -19,6 +19,7 @@ import { ShareFormPanel } from "../forms/ShareFormPanel";
|
||||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
|
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
|
||||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||||
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||||
|
|
@ -274,7 +275,8 @@ export function EventHomeModules() {
|
||||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = 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 [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||||
|
|
@ -1140,6 +1142,25 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{canReadEmailCampaigns ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Mail size={18} />}
|
icon={<Mail size={18} />}
|
||||||
|
|
@ -1327,7 +1348,7 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showDocumentsPanel && selectedEventId ? (
|
{showDocumentsPanel && selectedEventId ? (
|
||||||
<EventDocumentsPanel
|
<EventDocumentsPanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
eventName={eventName ?? undefined}
|
eventName={eventName ?? undefined}
|
||||||
|
|
@ -1335,7 +1356,16 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showEmailCampaignsPanel && selectedEventId ? (
|
{showEventPhotoSearchPanel && selectedEventId ? (
|
||||||
|
<EventPhotoSearchPanel
|
||||||
|
open
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
onClose={() => setShowEventPhotoSearchPanel(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEmailCampaignsPanel && selectedEventId ? (
|
||||||
<EventEmailCampaignsPanel
|
<EventEmailCampaignsPanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
eventName={eventName ?? undefined}
|
eventName={eventName ?? undefined}
|
||||||
|
|
|
||||||
|
|
@ -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/jpg"
|
||||||
|
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, JPG
|
||||||
|
</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/jpg"
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ export { eventsApi } from './events';
|
||||||
export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } from './rewardPoints';
|
export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } from './rewardPoints';
|
||||||
export { participantsApi, type ParticipantWithEvent } from './participants';
|
export { participantsApi, type ParticipantWithEvent } from './participants';
|
||||||
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
||||||
|
export { eventPhotoApi, type EventPhotoRow } from './eventPhoto';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
attendancesApi,
|
attendancesApi,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue