From 11fc7cc3ce3702210c9a846dd2b1e753690b90d3 Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 12 Aug 2026 12:16:35 +0530 Subject: [PATCH] Add event photo search and picture wall --- package-lock.json | 10 + package.json | 1 + src/App.tsx | 7 + src/api/client.ts | 101 ++++- src/api/eventPhoto.ts | 56 +++ src/pages/EventPhotoSearchPage.tsx | 571 ++++++++++++++++++++++++++++ src/pages/PictureWallPage.tsx | 147 +++++++ src/pages/PictureWallUploadPage.tsx | 275 ++++++++++++++ 8 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 src/api/eventPhoto.ts create mode 100644 src/pages/EventPhotoSearchPage.tsx create mode 100644 src/pages/PictureWallPage.tsx create mode 100644 src/pages/PictureWallUploadPage.tsx diff --git a/package-lock.json b/package-lock.json index 3943f8b..e260ff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "flatpickr": "^4.6.13", + "lucide-react": "^1.31.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-bootstrap": "^2.10.10", @@ -2276,6 +2277,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/media-chrome": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz", diff --git a/package.json b/package.json index 2180c96..3d3e245 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "flatpickr": "^4.6.13", + "lucide-react": "^1.31.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-bootstrap": "^2.10.10", diff --git a/src/App.tsx b/src/App.tsx index 3beae8d..5a21bff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,9 @@ import { LandingPage } from './pages/LandingPage'; import { EventMicrositePage } from './pages/EventMicrositePage'; import { RegisterPage } from './pages/RegisterPage'; import { FormPage } from './pages/FormPage'; +import { EventPhotoSearchPage } from './pages/EventPhotoSearchPage'; +import { PictureWallPage } from './pages/PictureWallPage'; +import { PictureWallUploadPage } from './pages/PictureWallUploadPage'; import { NotFoundPage } from './pages/NotFoundPage'; export default function App() { @@ -10,9 +13,13 @@ export default function App() { } /> + } /> } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/src/api/client.ts b/src/api/client.ts index b22529f..ba87db5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -17,6 +17,73 @@ export const PUBLIC_WEB_BASE = (import.meta.env.VITE_PUBLIC_WEB_BASE as string | undefined)?.replace(/\/$/, '') || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5175'); +const STORAGE_KEYS = { + access: 'eventbuzz_access_token', + refresh: 'eventbuzz_refresh_token', +} as const; + +let accessToken: string | null = null; +let refreshToken: string | null = null; + +function restoreTokensFromStorage() { + try { + const access = window.localStorage.getItem(STORAGE_KEYS.access); + const refresh = window.localStorage.getItem(STORAGE_KEYS.refresh); + if (access) accessToken = access; + if (refresh) refreshToken = refresh; + } catch { + // ignore storage issues + } +} + +restoreTokensFromStorage(); + +export function setTokens(access: string, refresh: string) { + accessToken = access; + refreshToken = refresh; + try { + window.localStorage.setItem(STORAGE_KEYS.access, access); + window.localStorage.setItem(STORAGE_KEYS.refresh, refresh); + } catch { + // ignore storage issues + } +} + +export function clearTokens() { + accessToken = null; + refreshToken = null; + try { + window.localStorage.removeItem(STORAGE_KEYS.access); + window.localStorage.removeItem(STORAGE_KEYS.refresh); + } catch { + // ignore storage issues + } +} + +async function doRefresh(): Promise { + if (!refreshToken) return false; + const base = API_BASE_URL.replace(/\/api\/?$/, ''); + + try { + const res = await fetch(`${base}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + + const body = await res.json().catch(() => null); + if (res.ok && body?.success && body?.data?.token) { + setTokens(body.data.token, refreshToken); + return true; + } + } catch { + // ignore refresh failures and proceed with clear state + } + + clearTokens(); + return false; +} + type ApiEnvelope = { success?: boolean; data?: T; @@ -27,7 +94,9 @@ export async function apiGet(path: string): Promise { const url = path.startsWith('http') ? path : `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; let res: Response; try { - res = await fetch(url); + res = await fetch(url, { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); } catch { throw new Error('Could not reach the API. If this persists, the server may be down or blocking cross-origin requests.'); } @@ -52,7 +121,10 @@ export async function apiPost(path: string, payload: unknown): Promise { try { res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, body: JSON.stringify(payload), }); } catch { @@ -72,3 +144,28 @@ export async function apiPost(path: string, payload: unknown): Promise { } return body.data as T; } + +export async function fetchWithAuth(path: string, init: RequestInit = {}): Promise { + const url = path.startsWith('http') ? path : `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; + const headers = new Headers(init.headers || {}); + + if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + + if (typeof FormData !== 'undefined' && init.body instanceof FormData) { + headers.delete('Content-Type'); + } + + let res = await fetch(url, { ...init, headers }); + + if (res.status === 401 && refreshToken) { + const refreshed = await doRefresh(); + if (refreshed && accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); + res = await fetch(url, { ...init, headers }); + } + } + + return res; +} diff --git a/src/api/eventPhoto.ts b/src/api/eventPhoto.ts new file mode 100644 index 0000000..32a68e3 --- /dev/null +++ b/src/api/eventPhoto.ts @@ -0,0 +1,56 @@ +import { API_BASE_URL, fetchWithAuth } 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; + similarity?: number; +} + +export const eventPhotoApi = { + searchPublicEventPhotos(eventId: string, file: File) { + const fd = new FormData(); + fd.append("files", file, file.name); + + return fetch( + `${API_BASE_URL}/events/${eventId}/search`, + { + method: "POST", + body: fd, + } + ); +}, + + async listPublicEventPhotos(eventId: string) { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://api.eventbuzz.live/api'}/events/${eventId}/picture-wall`); + const body = await response.json(); + if (!response.ok || !body?.success) { + throw new Error(body?.error?.message || 'Could not load the picture wall.'); + } + return body.data; + }, + + async uploadPublicEventPhotos(eventId: string, files: File[]) { + const fd = new FormData(); + files.forEach((file) => fd.append('files', file)); + + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://api.eventbuzz.live/api'}/events/${eventId}/picture-wall`, { + method: 'POST', + body: fd, + }); + const body = await response.json(); + if (!response.ok || !body?.success) { + throw new Error(body?.error?.message || 'Could not upload the picture wall photo.'); + } + return body.data; + }, +}; diff --git a/src/pages/EventPhotoSearchPage.tsx b/src/pages/EventPhotoSearchPage.tsx new file mode 100644 index 0000000..b74016a --- /dev/null +++ b/src/pages/EventPhotoSearchPage.tsx @@ -0,0 +1,571 @@ +import React, { useState } from "react"; +import { Search, Upload, X } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { eventPhotoApi } from "../api/eventPhoto"; + +export function EventPhotoSearchPage() { + const { eventId = "" } = useParams<{ eventId: string }>(); + + const [queryFile, setQueryFile] = useState(null); + const [searching, setSearching] = useState(false); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + + if (!file) return; + + setQueryFile(file); + setResults([]); + setError(null); + }; + + const clearFile = () => { + setQueryFile(null); + setResults([]); + setError(null); + }; + + const handleSearch = async () => { + if (!eventId) { + setError("Missing event id."); + return; + } + + if (!queryFile) { + setError("Please select a photo."); + return; + } + + setSearching(true); + setError(null); + + try { + const response = + await eventPhotoApi.searchPublicEventPhotos( + eventId, + queryFile + ); + + if (!response.ok) { + let message = response.statusText || "Search failed"; + try { + const body = await response.json(); + message = body?.error?.message || body?.message || message; + } catch { + // Ignore JSON parse errors and use the default HTTP error + } + throw new Error(message); + } + + const body = await response.json(); + const matches = Array.isArray(body?.data?.matches) ? body.data.matches : []; + setResults(matches); + + if (matches.length === 0 && body?.message) { + setError(body.message); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Photo search failed." + ); + setResults([]); + } finally { + setSearching(false); + } + }; + + return ( +
+
+ {/* Page Header */} +
+
+ Event Photo Search +
+ +

+ Search Event Photos +

+ +

+ Upload a photo to find matching photos from this event. +

+
+ + {/* Search Card */} +
+ {/* Card Header */} +
+
+ +
+ +
+
+ Find Photos +
+ +
+ Select one photo to search for matching event photos. +
+
+
+ + + + {!queryFile ? ( + /* Upload Area */ + + ) : ( + /* Selected Photo */ +
+
+
+
+ +
+ +
+
+ {queryFile.name} +
+ +
+ Query photo selected +
+
+
+ + +
+ + {/* Search Button */} + +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Loading */} + {searching && ( +
+ Searching matching photos... +
+ )} + + {/* Results */} + {results.length > 0 && ( +
+
+
+

+ Matching Photos +

+ +

+ {results.length} matching photo + {results.length !== 1 ? "s" : ""} found +

+
+
+ +
+ {results.map((photo) => ( +
+ {photo.url && ( + { + )} + +
+
+ {photo.originalFileName || "Event photo"} +
+ + {typeof photo.similarity === "number" && ( +
+ Match:{" "} + {( + Math.round(photo.similarity * 10) / 10 + ).toFixed(1)} + % +
+ )} +
+
+ ))} +
+
+ )} + + {/* No Results */} + {!searching && + queryFile && + results.length === 0 && + !error && ( +
+ + +
+ No matching photos +
+ +
+ Search for another photo from this event. +
+
+ )} +
+
+); +} \ No newline at end of file diff --git a/src/pages/PictureWallPage.tsx b/src/pages/PictureWallPage.tsx new file mode 100644 index 0000000..43783e3 --- /dev/null +++ b/src/pages/PictureWallPage.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Camera, RefreshCw } from 'lucide-react'; +import { eventPhotoApi } from '../api/eventPhoto'; +import { useNavigate } from 'react-router-dom'; + +function formatWhen(iso?: string) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + } catch { + return '—'; + } +} + +export function PictureWallPage() { + const { eventId = '' } = useParams<{ eventId: string }>(); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const loadPhotos = async (showLoading = true) => { + if (!eventId) return; + + if (showLoading) { + setLoading(true); + } + + setError(null); + + try { + const data = await eventPhotoApi.listPublicEventPhotos(eventId); + setPhotos(data?.photos ?? []); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Unable to load the picture wall.' + ); + } finally { + if (showLoading) { + setLoading(false); + } + } +}; + + useEffect(() => { + void loadPhotos(); + }, [eventId]); + + useEffect(() => { + if (!eventId) return; + + const interval = window.setInterval(() => { + void loadPhotos(); + }, 10000); + + return () => window.clearInterval(interval); + }, [eventId]); + + + + return ( +
+
+
+
+

+ Picture Wall +

+

Event Photos

+
+ +
+ +
+
+ +
+
+
+
+ +
+
+
Share your moments
+
Upload images for everyone in this event to see
+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +
Loading photos…
+ ) : photos.length === 0 ? ( +
+ No photos have been shared yet. Be the first to upload one. +
+ ) : ( +
+ {photos.map((photo) => ( +
+ {photo.originalFileName +
+
+ {photo.originalFileName || 'Uploaded photo'} +
+
+ {formatWhen(photo.uploadedAt)} +
+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/src/pages/PictureWallUploadPage.tsx b/src/pages/PictureWallUploadPage.tsx new file mode 100644 index 0000000..a2e0ca3 --- /dev/null +++ b/src/pages/PictureWallUploadPage.tsx @@ -0,0 +1,275 @@ +import { useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ImagePlus, Upload, X } from 'lucide-react'; +import { eventPhotoApi } from '../api/eventPhoto'; + +export function PictureWallUploadPage() { + const { eventId = '' } = useParams<{ eventId: string }>(); + + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const fileInputRef = useRef(null); + + const handleFileChange = ( + event: React.ChangeEvent, + ) => { + const files = Array.from(event.target.files || []); + + setSelectedFiles(files); + setError(null); + setSuccess(null); + }; + + const removeFile = (index: number) => { + setSelectedFiles((current) => + current.filter((_, i) => i !== index), + ); + }; + + const handleUpload = async () => { + if (!eventId) { + setError('Missing event ID.'); + return; + } + + if (selectedFiles.length === 0) { + setError('Please select at least one photo.'); + return; + } + + setUploading(true); + setError(null); + setSuccess(null); + + try { + await eventPhotoApi.uploadPublicEventPhotos( + eventId, + selectedFiles, + ); + + setSelectedFiles([]); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + + setSuccess('Photos uploaded successfully.'); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Image upload failed.', + ); + } finally { + setUploading(false); + } + }; + + return ( +
+
+
+
+ + +

+ Share Your Photos +

+ +

+ Upload photos from this event. +

+
+ + + + + + {selectedFiles.length > 0 && ( +
+ + {selectedFiles.length} photo + {selectedFiles.length === 1 ? '' : 's'} selected + + +
+ {selectedFiles.map((file, index) => ( +
+ + {file.name} + + + +
+ ))} +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + +
+
+
+ ); +} \ No newline at end of file