Event-photo search and picture wall changes

This commit is contained in:
Tanuja Pansare 2026-08-12 12:12:43 +05:30
parent 8e8f506f9a
commit 5e93d5d023
11 changed files with 780 additions and 500 deletions

View File

@ -38,6 +38,7 @@ export default function App() {
<Route path="plans" element={<SuperAdminPlansPage />} />
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
</Route>
<Route path=":eventId/searchphoto" element={<ProtectedLayout.EventPhotoSearchShell />} />
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
</Route>
<Route path="*" element={<Navigate to="/home" replace />} />

View File

@ -35,6 +35,7 @@ import { RolesPage } from "./roles/RolesPage";
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
import { SearchResultsPage } from "./search/SearchResultsPage";
import { EventPhotoSearchPage } from "./events/EventPhotoSearchPage";
import { eventsApi } from "../../services/api";
import type { Event } from "../../services/api/types";
@ -141,6 +142,7 @@ export function MainContent() {
if (activeNav === "participants") return <ParticipantsPage />;
if (activeNav === "attendance") return <AttendancePage />;
if (activeNav === "ticketing") return <EventTicketingPage />;
if (activeNav === "photosearch") return <EventPhotoSearchPage />;
if (activeNav === "permissions") return <PermissionsPage />;
if (activeNav === "event-activities") return <EventActivitiesPage />;
if (activeNav === "rewards") return <RewardPointsPage eventId={selectedEventId} />;

View File

@ -42,7 +42,7 @@ const EVENT_NAV_TEXT = "#484B57";
const EVENT_NAV_TEXT_MUTED = "rgba(72, 75, 87, 0.72)";
/** Event sub-routes that keep the event NavBar visible (includes hidden tabs like the website builder). */
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz"]);
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz", "photosearch"]);
/* ─────────────────────────────────────
NavBar component
@ -181,4 +181,4 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
)}
</div>
);
}
}

View File

@ -275,7 +275,9 @@ 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 [pictureWallEnabled, setPictureWallEnabled] = useState(false);
const [pictureWallLoading, setPictureWallLoading] = useState(false);
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
const [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
@ -286,6 +288,22 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
const [editingExhibitor, setEditingExhibitor] = useState<ApiExhibitor | null>(null);
const actionsDisabled = !selectedEventId;
const photoSearchHeaderButtonStyle: React.CSSProperties = {
width: "26px",
height: "26px",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--muted)",
cursor: actionsDisabled ? "not-allowed" : "pointer",
opacity: actionsDisabled ? 0.55 : 1,
flexShrink: 0,
padding: 0,
};
useEffect(() => {
if (!selectedEventId) {
@ -298,6 +316,7 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
setEventName(res.data?.name ?? null);
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
setPictureWallEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.pictureWallEnabled));
});
return () => {
cancelled = true;
@ -575,7 +594,6 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
[selectedEventId],
);
const actionsDisabled = !selectedEventId;
const pollQuizMeta = !selectedEventId
? "Select an event to create polls and quizzes"
: pollQuizLoading
@ -630,6 +648,13 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
: webCheckInEnabled
? "Enabled"
: "Disabled";
const pictureWallMeta = !selectedEventId
? "Select an event to configure picture wall"
: pictureWallLoading
? "Saving…"
: pictureWallEnabled
? "Enabled"
: "Disabled";
const handleWebCheckInToggle = async (enabled: boolean) => {
if (!selectedEventId) return;
@ -646,6 +671,21 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
}
};
const handlePictureWallToggle = async (enabled: boolean) => {
if (!selectedEventId) return;
setPictureWallLoading(true);
try {
const nextSettings = { ...(eventSettings || {}), pictureWallEnabled: enabled };
const res = await eventsApi.update(selectedEventId, { settings: nextSettings });
if (!res.error) {
setEventSettings(nextSettings);
setPictureWallEnabled(enabled);
}
} finally {
setPictureWallLoading(false);
}
};
const handleSavePollQuiz = async (
kind: "poll" | "quiz",
data: { form: QuizPollModalForm; questions: Question[] },
@ -1142,10 +1182,104 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
}
/>
) : null}
<ModuleCard
icon={<Image size={18} />}
title="Picture Wall"
meta={pictureWallMeta}
description="Enable a public gallery where guests can add and view event photos"
headerAction={
pictureWallEnabled ? (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 }}>
<button
type="button"
title="Copy public picture wall link"
disabled={actionsDisabled}
style={{
...formLinkCopyButtonStyle(false),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!selectedEventId) return;
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall/upload`;
void navigator.clipboard.writeText(url);
}}
>
<Link2 size={12} />
</button>
<button
type="button"
title="Share picture wall"
disabled={actionsDisabled}
style={{
...formHeaderIconButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!selectedEventId) return;
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall`;
if (navigator.share) {
void navigator.share({ title: 'Event picture wall', url });
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}}
>
<Share2 size={12} />
</button>
</div>
) : null
}
actions={
pictureWallEnabled ? (
<button
type="button"
disabled={actionsDisabled || pictureWallLoading}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
}}
onClick={() => void handlePictureWallToggle(false)}
>
Disable
</button>
) : (
<button
type="button"
disabled={actionsDisabled || pictureWallLoading}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
}}
onClick={() => void handlePictureWallToggle(true)}
>
Enable
</button>
)
}
/>
<ModuleCard
icon={<Image size={18} />}
title="Event Photo Search"
description="Upload event photos and search by image"
headerAction={
<button
type="button"
title="Open photo search page"
aria-label="Open photo search page"
disabled={actionsDisabled}
style={photoSearchHeaderButtonStyle}
onClick={() => {
if (!selectedEventId) return;
window.location.href = `https://web.eventbuzz.live/${selectedEventId}/searchphoto`;
}}
>
<Link2 size={12} />
</button>
}
actions={
<button
type="button"

View File

@ -0,0 +1,111 @@
import React, { useEffect, useState } from "react";
import { ArrowLeft, Search } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { eventsApi } from "../../../services/api/events";
import type { Event } from "../../../services/api/types";
import { useAppNav } from "../../contexts/AppNavContext";
import { buildEventNavPath } from "../../utils/eventNavUrl";
import { EventPhotoSearchContent } from "../panels/EventPhotoSearchContent";
export function EventPhotoSearchPage() {
const { eventId = "" } = useParams<{ eventId: string }>();
const navigate = useNavigate();
const { setSelectedEventId } = useAppNav();
const [event, setEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!eventId) {
setLoading(false);
setError("Missing event id.");
return;
}
setSelectedEventId(eventId);
}, [eventId, setSelectedEventId]);
useEffect(() => {
if (!eventId) return;
let cancelled = false;
setLoading(true);
setError(null);
void eventsApi.get(eventId)
.then((res) => {
if (cancelled) return;
if (res.error || !res.data) {
setEvent(null);
setError(res.error || "Could not load event.");
return;
}
setEvent(res.data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18, minHeight: 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<div style={{ minWidth: 0 }}>
<button
type="button"
onClick={() => navigate(buildEventNavPath("dashboard", eventId))}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
border: "none",
background: "transparent",
color: "var(--muted)",
padding: 0,
marginBottom: 8,
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
}}
>
<ArrowLeft size={14} />
Back to Event Home
</button>
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Event Photo Search
</p>
<h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: 1.2 }}>
{loading ? "Loading…" : event?.name || "Photo Search"}
</h1>
</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--muted)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
}}
>
<Search size={14} />
Query photo only
</div>
</div>
{error ? (
<div style={{ padding: "16px 18px", borderRadius: "var(--radius-card)", border: "1px solid color-mix(in srgb, var(--destructive) 30%, var(--border))", background: "var(--card)", color: "var(--destructive)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
{error}
</div>
) : null}
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden", minHeight: "70vh" }}>
<EventPhotoSearchContent active={!!eventId} eventId={eventId} mode="search-only" />
</div>
</div>
);
}

View File

@ -0,0 +1,493 @@
import React, { useEffect, useRef, useState } from "react";
import { Image, Search, Trash2, Upload, X } from "lucide-react";
import { eventPhotoApi } from "../../../services/api";
import type { EventPhotoRow } from "../../../services/api/eventPhoto";
type EventPhotoSearchMode = "full" | "search-only";
type Props = {
active: boolean;
eventId: string;
mode: EventPhotoSearchMode;
};
function formatWhen(iso?: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
} catch {
return "—";
}
}
export function EventPhotoSearchContent({ active, eventId, mode }: 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 isFullMode = mode === "full";
const loadPhotos = async () => {
if (!isFullMode) return;
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 (!active) return;
setErr(null);
setDeletedMsg(null);
setSearchResults(null);
setQueryFile(null);
setFiles([]);
if (queryFileInputRef.current) queryFileInputRef.current.value = "";
if (fileInputRef.current) fileInputRef.current.value = "";
if (isFullMode) {
void loadPhotos();
} else {
setPhotos([]);
setLoadingPhotos(false);
}
}, [active, eventId, isFullMode]);
const onUpload = async () => {
if (!isFullMode || 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) => {
if (!isFullMode) return;
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();
window.setTimeout(() => setDeletedMsg(null), 1800);
} catch (e) {
setErr(e instanceof Error ? e.message : "Delete failed");
}
};
const onFindPhotos = async () => {
if (!queryFile) return;
setSearching(true);
setErr(null);
try {
const r = await eventPhotoApi.searchEventPhotos(eventId, queryFile);
if (!r.ok) {
let msg = r.statusText || "Search failed";
try {
const j = await r.json();
msg = j?.error?.message || msg;
} catch {}
throw new Error(msg);
}
const data = await r.json();
setSearchResults(data?.data?.matches ?? []);
} catch (e) {
setErr(e instanceof Error ? e.message : "Search failed");
setSearchResults(null);
} finally {
setSearching(false);
}
};
const clearSearch = () => {
setSearchResults(null);
setQueryFile(null);
if (queryFileInputRef.current) queryFileInputRef.current.value = "";
};
const displayPhotos = isFullMode ? (searchResults !== null ? searchResults : photos) : searchResults ?? [];
const showEmptySearchState = !isFullMode && searchResults === null;
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
{isFullMode ? (
<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>
<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",
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
>
<Upload size={14} />
Choose Photo
</button>
<span
style={{
color: files.length > 0 ? "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) => setFiles(Array.from(e.target.files || []))}
/>
<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: 152,
height: 44,
padding: "0 20px",
alignSelf: "flex-start",
marginTop: 30,
border: "none",
borderRadius: "var(--radius-button)",
background: files.length === 0 || uploading ? "#cbd5e1" : "var(--primary)",
color: "#fff",
fontWeight: 600,
cursor: files.length === 0 || uploading ? "not-allowed" : "pointer",
transition: "all .2s",
}}
>
{uploading ? "Uploading..." : "Upload Photo"}
</button>
</div>
</div>
) : null}
<div
style={{
padding: isFullMode ? "12px 18px" : "18px 20px",
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)" }}>
{isFullMode ? "Find Photos" : "Query Photo Search"}
</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) 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,
}}
>
<Upload size={16} />
Search Photo
</button>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 10, flex: "1 1 auto", flexWrap: "wrap" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 240 }}>
{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,
}}
>
<Search size={16} />
{searching ? "Searching..." : "Search"}
</button>
<button
type="button"
onClick={clearSearch}
style={{
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--card)",
color: "var(--muted)",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
)}
</div>
<p style={{ margin: "8px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
{isFullMode
? "Upload one query photo to find matching event photos."
: "Select one photo to search the event photo collection. This page does not add photos to the event."}
</p>
{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={clearSearch}
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>
) : null}
</div>
<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>
) : showEmptySearchState ? (
<div style={{ padding: "26px 18px" }}>
<div style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", padding: "28px 16px", textAlign: "center", background: "var(--card)" }}>
<Search 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 }}>
Search event photos
</p>
<p style={{ margin: "8px 0 0", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
Choose one query photo to see matching results for this event.
</p>
</div>
</div>
) : displayPhotos.length === 0 ? (
<div style={{ padding: "26px 18px" }}>
<div style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", padding: "22px 16px", textAlign: "center", background: "var(--card)" }}>
<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 }}>
{isFullMode && searchResults === null ? "No photos" : "No matches found"}
</p>
<p style={{ margin: "8px 0 0", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
{isFullMode && searchResults === null
? "Upload the first photo for this event."
: "Try a different query 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: "2px 0 0", fontSize: 10, color: "var(--muted)" }}>
{formatWhen(p.uploadedAt)}
</p>
{typeof p.similarity === "number" ? (
<p style={{ margin: "4px 0 0", fontSize: 10, color: "var(--primary)", fontWeight: 600 }}>
Match {(Math.round(p.similarity * 10) / 10).toFixed(1)}%
</p>
) : null}
<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>
{isFullMode ? (
<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>
) : null}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,7 +1,6 @@
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';
import React from "react";
import { X } from "lucide-react";
import { EventPhotoSearchContent } from "./EventPhotoSearchContent";
type Props = {
open: boolean;
@ -10,139 +9,10 @@ type Props = {
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;
// const displayPhotos = searchResults !== null ? searchResults : photos;
return (
<>
@ -203,7 +73,7 @@ setSearchResults(data.data.matches ?? []);
{eventName || 'Photos'}
</h2>
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
Upload event photos
Upload event photos
</p>
</div>
@ -229,362 +99,8 @@ setSearchResults(data.data.matches ?? []);
</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>
<EventPhotoSearchContent active={open} eventId={eventId} mode="full" />
</div>
</>
);
}

View File

@ -55,19 +55,21 @@ interface AppNavProviderProps {
children: React.ReactNode;
/** When set, nav tab comes from the URL and event id is synced via ?event= */
activeNavFromUrl?: string;
initialSelectedEventId?: string | null;
}
const DEFAULT_NAV = "home";
export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderProps) {
export function AppNavProvider({ children, activeNavFromUrl, initialSelectedEventId }: AppNavProviderProps) {
const navigate = useNavigate();
const location = useLocation();
const urlRouting = activeNavFromUrl !== undefined;
const [internalNav, setInternalNav] = useState(DEFAULT_NAV);
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() =>
urlRouting ? readEventIdFromSearch(window.location.search) : null,
);
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() => {
if (initialSelectedEventId !== undefined) return initialSelectedEventId;
return urlRouting ? readEventIdFromSearch(window.location.search) : null;
});
const selectedEventIdRef = useRef(selectedEventId);
selectedEventIdRef.current = selectedEventId;
@ -83,10 +85,16 @@ export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderPro
// Restore selected event from URL on refresh / back-forward.
useEffect(() => {
if (initialSelectedEventId !== undefined) return;
if (!urlRouting) return;
const fromUrl = readEventIdFromSearch(location.search);
setSelectedEventIdState((prev) => (prev === fromUrl ? prev : fromUrl));
}, [location.search, urlRouting]);
}, [initialSelectedEventId, location.search, urlRouting]);
useEffect(() => {
if (initialSelectedEventId === undefined) return;
setSelectedEventIdState((prev) => (prev === initialSelectedEventId ? prev : initialSelectedEventId));
}, [initialSelectedEventId]);
// Keep ?event= on event-scoped tabs when selection changes in memory.
useEffect(() => {

View File

@ -13,6 +13,9 @@ export function readEventIdFromSearch(search: string): string | null {
/** Build app path for a nav tab, attaching ?event= when the tab is event-scoped. */
export function buildEventNavPath(navId: string, eventId?: string | null): string {
if (navId === "photosearch" && eventId) {
return `/${encodeURIComponent(eventId)}/searchphoto`;
}
const path = `/${navId}`;
if (eventId && isEventScopedNav(navId)) {
return `${path}?${EVENT_QUERY_PARAM}=${encodeURIComponent(eventId)}`;

View File

@ -35,3 +35,14 @@ function AppShellWithNav() {
}
ProtectedLayout.AppShellWithNav = AppShellWithNav;
function EventPhotoSearchShell() {
const { eventId } = useParams<{ eventId: string }>();
return (
<AppNavProvider activeNavFromUrl="photosearch" initialSelectedEventId={eventId || null}>
<AppShell />
</AppNavProvider>
);
}
ProtectedLayout.EventPhotoSearchShell = EventPhotoSearchShell;

View File

@ -1,4 +1,4 @@
import { api, fetchWithAuth, API_BASE_URL } from './client';
import { api, fetchWithAuth } from './client';
export interface EventPhotoRow {
_id: string;
@ -17,6 +17,7 @@ export interface EventPhotoRow {
embedding_generated: boolean;
uploadedAt?: string;
similarity?: number;
}
export const eventPhotoApi = {