diff --git a/src/app/App.tsx b/src/app/App.tsx index 0b4aea3d..8a279341 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -38,6 +38,7 @@ export default function App() { } /> } /> + } /> } /> } /> diff --git a/src/app/components/MainContent.tsx b/src/app/components/MainContent.tsx index 7fdeed4a..1b4c1b66 100644 --- a/src/app/components/MainContent.tsx +++ b/src/app/components/MainContent.tsx @@ -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 ; if (activeNav === "attendance") return ; if (activeNav === "ticketing") return ; + if (activeNav === "photosearch") return ; if (activeNav === "permissions") return ; if (activeNav === "event-activities") return ; if (activeNav === "rewards") return ; diff --git a/src/app/components/NavBar.tsx b/src/app/components/NavBar.tsx index b4f33a2f..c6c3a991 100644 --- a/src/app/components/NavBar.tsx +++ b/src/app/components/NavBar.tsx @@ -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) { )} ); -} \ No newline at end of file +} diff --git a/src/app/components/dashboard/EventHomeModules.tsx b/src/app/components/dashboard/EventHomeModules.tsx index c071a1e9..587209e2 100644 --- a/src/app/components/dashboard/EventHomeModules.tsx +++ b/src/app/components/dashboard/EventHomeModules.tsx @@ -275,7 +275,9 @@ export function EventHomeModules() { const [eventSettings, setEventSettings] = useState>({}); 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(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 | undefined) ?? {}); setWebCheckInEnabled(Boolean((res.data?.settings as Record | undefined)?.webCheckInEnabled)); + setPictureWallEnabled(Boolean((res.data?.settings as Record | 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} + } + title="Picture Wall" + meta={pictureWallMeta} + description="Enable a public gallery where guests can add and view event photos" + headerAction={ + pictureWallEnabled ? ( +
+ + +
+ ) : null + } + actions={ + pictureWallEnabled ? ( + + ) : ( + + ) + } + /> } title="Event Photo Search" description="Upload event photos and search by image" + headerAction={ + + } actions={ +

+ Event Photo Search +

+

+ {loading ? "Loading…" : event?.name || "Photo Search"} +

+ +
+ + Query photo only +
+ + + {error ? ( +
+ {error} +
+ ) : null} + +
+ +
+ + ); +} diff --git a/src/app/components/panels/EventPhotoSearchContent.tsx b/src/app/components/panels/EventPhotoSearchContent.tsx new file mode 100644 index 00000000..8ca2e831 --- /dev/null +++ b/src/app/components/panels/EventPhotoSearchContent.tsx @@ -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([]); + const [err, setErr] = useState(null); + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + const [queryFile, setQueryFile] = useState(null); + const queryFileInputRef = useRef(null); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); + const [deletedMsg, setDeletedMsg] = useState(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 ( +
+ {isFullMode ? ( +
+
+
+ +
+ + 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(", ")} + +
+ setFiles(Array.from(e.target.files || []))} + /> +

+ Supported: JPEG, PNG, JPG +

+
+ +
+
+ ) : null} + +
+
+
+ + + {isFullMode ? "Find Photos" : "Query Photo Search"} + +
+ { + const f = e.target.files?.[0] || null; + setQueryFile(f); + if (f) setSearchResults(null); + }} + /> + {!queryFile ? ( + + ) : ( +
+ + {queryFile.name} + + + +
+ )} +
+

+ {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."} +

+ {searchResults !== null ? ( +

+ Showing {searchResults.length} result{searchResults.length === 1 ? "" : "s"} + +

+ ) : null} +
+ +
+ {err ? ( +

{err}

+ ) : null} + {deletedMsg ? ( +

{deletedMsg}

+ ) : null} + + {loadingPhotos ? ( +

Loading…

+ ) : showEmptySearchState ? ( +
+
+ +

+ Search event photos +

+

+ Choose one query photo to see matching results for this event. +

+
+
+ ) : displayPhotos.length === 0 ? ( +
+
+ +

+ {isFullMode && searchResults === null ? "No photos" : "No matches found"} +

+

+ {isFullMode && searchResults === null + ? "Upload the first photo for this event." + : "Try a different query photo for this event."} +

+
+
+ ) : ( +
+
+ {displayPhotos.map((p) => ( +
+
+ {p.url ? ( + {p.originalFileName} + ) : ( + + )} +
+
+

+ {p.originalFileName} +

+

+ {formatWhen(p.uploadedAt)} +

+ {typeof p.similarity === "number" ? ( +

+ Match {(Math.round(p.similarity * 10) / 10).toFixed(1)}% +

+ ) : null} +
+ + {isFullMode ? ( + + ) : null} +
+
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/app/components/panels/EventPhotoSearchPanel.tsx b/src/app/components/panels/EventPhotoSearchPanel.tsx index 7c584253..f56791d6 100644 --- a/src/app/components/panels/EventPhotoSearchPanel.tsx +++ b/src/app/components/panels/EventPhotoSearchPanel.tsx @@ -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([]); - const [err, setErr] = useState(null); - - - const [files, setFiles] = useState([]); - const [uploading, setUploading] = useState(false); - const fileInputRef = useRef(null); - - const [queryFile, setQueryFile] = useState(null); - const queryFileInputRef = useRef(null); - const [searchResults, setSearchResults] = useState(null); - const [searching, setSearching] = useState(false); - - const [deletedMsg, setDeletedMsg] = useState(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'}

- Upload event photos + Upload event photos

@@ -229,362 +99,8 @@ setSearchResults(data.data.matches ?? []); - {/* Upload Section */} -
-
-
- - {/* 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', - }} - /> */} -
- - - {files.length === 0 ? ( - "No files selected" - ) : ( - files.map(f => f.name).join(", ") - )} - -
- { - const selected = Array.from(e.target.files || []); - setFiles(selected); - }} - /> -

- Supported: JPEG, PNG, JPG -

-
- -
-
- - {/* Find Photos Section */} -
-
-
- - - Find Photos - -
- { - const f = e.target.files?.[0] || null; - setQueryFile(f); - if (f) { - // Auto-trigger search when file is selected - setSearchResults(null); - } - }} - /> - {!queryFile ? ( - - ) : ( -
- - {queryFile.name} - - - -
- )} -
- {searchResults !== null && ( -

- Showing {searchResults.length} result{searchResults.length === 1 ? '' : 's'} - -

- )} -
- - {/* Photo List */} -
- {err ? ( -

{err}

- ) : null} - {deletedMsg ? ( -

{deletedMsg}

- ) : null} - - {loadingPhotos ? ( -

Loading…

- ) : displayPhotos.length === 0 ? ( -
-
- -

- No photos -

-

- Upload the first photo for this event. -

-
-
- ) : ( -
-
- {displayPhotos.map((p) => ( -
-
- {p.url ? ( - {p.originalFileName} - ) : ( - - )} -
-
-

- {p.originalFileName} -

- {/*

- Document ID: {p._id} -

*/} -

- {formatWhen(p.uploadedAt)} -

- {/* {p.embedding_generated && ( - - Embedding Ready - - )} */} -
- - -
-
-
- ))} -
-
- )} -
+ ); } - diff --git a/src/app/contexts/AppNavContext.tsx b/src/app/contexts/AppNavContext.tsx index bffeb276..6c158700 100644 --- a/src/app/contexts/AppNavContext.tsx +++ b/src/app/contexts/AppNavContext.tsx @@ -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(() => - urlRouting ? readEventIdFromSearch(window.location.search) : null, - ); + const [selectedEventId, setSelectedEventIdState] = useState(() => { + 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(() => { diff --git a/src/app/utils/eventNavUrl.ts b/src/app/utils/eventNavUrl.ts index 65d50963..e3cb75c7 100644 --- a/src/app/utils/eventNavUrl.ts +++ b/src/app/utils/eventNavUrl.ts @@ -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)}`; diff --git a/src/app/views/ProtectedLayout.tsx b/src/app/views/ProtectedLayout.tsx index 19c4b360..216a1d8e 100644 --- a/src/app/views/ProtectedLayout.tsx +++ b/src/app/views/ProtectedLayout.tsx @@ -35,3 +35,14 @@ function AppShellWithNav() { } ProtectedLayout.AppShellWithNav = AppShellWithNav; + +function EventPhotoSearchShell() { + const { eventId } = useParams<{ eventId: string }>(); + return ( + + + + ); +} + +ProtectedLayout.EventPhotoSearchShell = EventPhotoSearchShell; diff --git a/src/services/api/eventPhoto.ts b/src/services/api/eventPhoto.ts index d0ab5d50..7b4b7832 100644 --- a/src/services/api/eventPhoto.ts +++ b/src/services/api/eventPhoto.ts @@ -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 = {