Add event photo search and picture wall

This commit is contained in:
Tanuja Pansare 2026-08-12 12:16:35 +05:30
parent c9519ac24c
commit 11fc7cc3ce
8 changed files with 1166 additions and 2 deletions

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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() {
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/event/:eventId/picturewall" element={<PictureWallPage />} />
<Route path="/event/:eventId" element={<EventMicrositePage />} />
<Route path="/event/:eventId/register" element={<RegisterPage />} />
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />
<Route path="/:eventId/searchphoto" element={<EventPhotoSearchPage />} />
<Route path="/event/:eventId/picturewall" element={<PictureWallPage />} />
<Route path="/event/:eventId/picturewall/upload" element={<PictureWallUploadPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>

View File

@ -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<boolean> {
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<T> = {
success?: boolean;
data?: T;
@ -27,7 +94,9 @@ export async function apiGet<T>(path: string): Promise<T> {
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<T>(path: string, payload: unknown): Promise<T> {
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<T>(path: string, payload: unknown): Promise<T> {
}
return body.data as T;
}
export async function fetchWithAuth(path: string, init: RequestInit = {}): Promise<Response> {
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;
}

56
src/api/eventPhoto.ts Normal file
View File

@ -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;
},
};

View File

@ -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<File | null>(null);
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div
style={{
minHeight: "100vh",
background: "#f8fafc",
padding: "32px 24px",
fontFamily: "var(--font-noto-sans)",
}}
>
<div
style={{
maxWidth: "1100px",
margin: "0 auto",
}}
>
{/* Page Header */}
<div style={{ marginBottom: "28px" }}>
<div
style={{
fontSize: "13px",
color: "#64748b",
marginBottom: "6px",
}}
>
Event Photo Search
</div>
<h1
style={{
margin: 0,
fontSize: "28px",
lineHeight: 1.2,
fontWeight: 600,
color: "#0f172a",
fontFamily: "var(--font-inter)",
}}
>
Search Event Photos
</h1>
<p
style={{
margin: "8px 0 0",
fontSize: "14px",
color: "#64748b",
}}
>
Upload a photo to find matching photos from this event.
</p>
</div>
{/* Search Card */}
<div
style={{
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "12px",
padding: "24px",
boxShadow: "0 1px 3px rgba(15, 23, 42, 0.06)",
}}
>
{/* Card Header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
marginBottom: "20px",
}}
>
<div
style={{
width: "42px",
height: "42px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
background: "#eef2ff",
color: "#f59e0b",
}}
>
<Search size={21} />
</div>
<div>
<div
style={{
fontSize: "17px",
fontWeight: 600,
color: "#0f172a",
}}
>
Find Photos
</div>
<div
style={{
marginTop: "2px",
fontSize: "13px",
color: "#64748b",
}}
>
Select one photo to search for matching event photos.
</div>
</div>
</div>
<input
id="query-photo"
type="file"
accept="image/jpeg,image/png,image/jpg"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{!queryFile ? (
/* Upload Area */
<label
htmlFor="query-photo"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "220px",
padding: "30px",
border: "1.5px dashed #cbd5e1",
borderRadius: "10px",
background: "#f8fafc",
cursor: "pointer",
transition: "all 0.2s ease",
textAlign: "center",
}}
>
<div
style={{
width: "54px",
height: "54px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: "#fff7ed",
color: "#f59e0b",
marginBottom: "14px",
}}
>
<Upload size={24} />
</div>
<div
style={{
fontSize: "15px",
fontWeight: 600,
color: "#0f172a",
}}
>
Select a photo
</div>
<div
style={{
marginTop: "6px",
fontSize: "13px",
color: "#64748b",
}}
>
Choose one JPG or PNG image from your device
</div>
<div
style={{
marginTop: "16px",
padding: "8px 16px",
border: "1px solid #f59e0b",
borderRadius: "7px",
background: "#ffffff",
color: "#d97706",
fontSize: "13px",
fontWeight: 600,
}}
>
Browse Photo
</div>
</label>
) : (
/* Selected Photo */
<div
style={{
border: "1px solid #e2e8f0",
borderRadius: "10px",
padding: "16px",
background: "#f8fafc",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "16px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
minWidth: 0,
}}
>
<div
style={{
width: "44px",
height: "44px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "8px",
background: "#fff7ed",
color: "#f59e0b",
flexShrink: 0,
}}
>
<Upload size={19} />
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: "14px",
fontWeight: 600,
color: "#0f172a",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{queryFile.name}
</div>
<div
style={{
marginTop: "3px",
fontSize: "12px",
color: "#64748b",
}}
>
Query photo selected
</div>
</div>
</div>
<button
type="button"
onClick={clearFile}
title="Remove selected photo"
aria-label="Remove selected photo"
style={{
width: "34px",
height: "34px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
border: "1px solid #e2e8f0",
borderRadius: "7px",
background: "#ffffff",
color: "#64748b",
cursor: "pointer",
}}
>
<X size={16} />
</button>
</div>
{/* Search Button */}
<button
type="button"
onClick={handleSearch}
disabled={searching}
style={{
width: "100%",
marginTop: "16px",
height: "44px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
border: "none",
borderRadius: "7px",
background: searching ? "#94a3b8" : "#2563eb",
color: "#ffffff",
cursor: searching ? "not-allowed" : "pointer",
fontSize: "14px",
fontWeight: 600,
}}
>
<Search size={17} />
{searching ? "Searching Photos..." : "Search Photos"}
</button>
</div>
)}
{error && (
<div
style={{
marginTop: "14px",
padding: "11px 14px",
borderRadius: "7px",
border: "1px solid #fecaca",
background: "#fef2f2",
color: "#dc2626",
fontSize: "13px",
}}
>
{error}
</div>
)}
</div>
{/* Loading */}
{searching && (
<div
style={{
marginTop: "24px",
padding: "24px",
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "12px",
textAlign: "center",
color: "#64748b",
fontSize: "14px",
}}
>
Searching matching photos...
</div>
)}
{/* Results */}
{results.length > 0 && (
<div style={{ marginTop: "28px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "14px",
}}
>
<div>
<h2
style={{
margin: 0,
fontSize: "18px",
fontWeight: 600,
color: "#0f172a",
}}
>
Matching Photos
</h2>
<p
style={{
margin: "4px 0 0",
fontSize: "13px",
color: "#64748b",
}}
>
{results.length} matching photo
{results.length !== 1 ? "s" : ""} found
</p>
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(220px, 1fr))",
gap: "16px",
}}
>
{results.map((photo) => (
<div
key={photo._id}
style={{
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "10px",
overflow: "hidden",
boxShadow:
"0 1px 3px rgba(15, 23, 42, 0.05)",
}}
>
{photo.url && (
<img
src={photo.url}
alt={
photo.originalFileName || "Event photo"
}
style={{
width: "100%",
height: "200px",
display: "block",
objectFit: "cover",
}}
/>
)}
<div style={{ padding: "12px" }}>
<div
style={{
fontSize: "13px",
color: "#0f172a",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{photo.originalFileName || "Event photo"}
</div>
{typeof photo.similarity === "number" && (
<div
style={{
marginTop: "6px",
fontSize: "12px",
color: "#64748b",
}}
>
Match:{" "}
{(
Math.round(photo.similarity * 10) / 10
).toFixed(1)}
%
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* No Results */}
{!searching &&
queryFile &&
results.length === 0 &&
!error && (
<div
style={{
marginTop: "24px",
padding: "30px",
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "12px",
textAlign: "center",
color: "#64748b",
}}
>
<Search
size={28}
style={{ marginBottom: "8px" }}
/>
<div
style={{
fontSize: "14px",
fontWeight: 600,
color: "#334155",
}}
>
No matching photos
</div>
<div
style={{
marginTop: "5px",
fontSize: "13px",
}}
>
Search for another photo from this event.
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)', padding: '32px 16px' }}>
<div style={{ maxWidth: '1180px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '20px' }}>
<div>
<p style={{ margin: 0, color: '#64748b', letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: '12px', fontWeight: 700 }}>
Picture Wall
</p>
<h1 style={{ margin: '6px 0 0', fontSize: 'clamp(2rem, 4vw, 3rem)', color: '#0f172a' }}>Event Photos</h1>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => void loadPhotos()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
border: '1px solid #dbeafe',
borderRadius: '999px',
background: '#fff',
color: '#0f172a',
padding: '10px 16px',
cursor: 'pointer',
fontWeight: 600,
}}
>
<RefreshCw size={16} />
Refresh
</button>
</div>
</div>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '24px', boxShadow: '0 18px 48px rgba(15, 23, 42, 0.05)', padding: '20px', marginBottom: '22px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div style={{ width: '42px', height: '42px', borderRadius: '14px', background: '#eef2ff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4f46e5' }}>
<Camera size={20} />
</div>
<div>
<div style={{ fontWeight: 700, color: '#0f172a' }}>Share your moments</div>
<div style={{ fontSize: '13px', color: '#64748b' }}>Upload images for everyone in this event to see</div>
</div>
</div>
</div>
</div>
{error ? (
<div style={{ background: '#fff1f2', border: '1px solid #fecdd3', color: '#9f1239', borderRadius: '16px', padding: '14px 16px', marginBottom: '18px' }}>
{error}
</div>
) : null}
{loading ? (
<div style={{ textAlign: 'center', padding: '48px 16px', color: '#64748b', fontWeight: 600 }}>Loading photos…</div>
) : photos.length === 0 ? (
<div style={{ background: '#fff', border: '1px dashed #cbd5e1', borderRadius: '20px', padding: '56px 20px', textAlign: 'center', color: '#64748b' }}>
No photos have been shared yet. Be the first to upload one.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '18px' }}>
{photos.map((photo) => (
<div key={photo._id} style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: '20px', overflow: 'hidden', boxShadow: '0 10px 24px rgba(15, 23, 42, 0.04)' }}>
<img src={photo.url} alt={photo.originalFileName || 'Event photo'} style={{ display: 'block', width: '100%', height: '260px', objectFit: 'cover', background: '#e2e8f0' }} />
<div style={{ padding: '12px 14px 14px' }}>
<div style={{ fontWeight: 700, color: '#0f172a', marginBottom: '4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{photo.originalFileName || 'Uploaded photo'}
</div>
<div style={{ fontSize: '12px', color: '#64748b' }}>
{formatWhen(photo.uploadedAt)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -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<File[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
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 (
<div
style={{
minHeight: '100vh',
background:
'linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%)',
padding: '40px 16px',
}}
>
<div
style={{
maxWidth: '700px',
margin: '0 auto',
}}
>
<div
style={{
background: '#fff',
border: '1px solid #e2e8f0',
borderRadius: '24px',
padding: '28px',
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<ImagePlus size={40} />
<h1
style={{
margin: '12px 0 6px',
color: '#0f172a',
}}
>
Share Your Photos
</h1>
<p
style={{
margin: 0,
color: '#64748b',
}}
>
Upload photos from this event.
</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/jpg,image/webp"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
width: '100%',
minHeight: '180px',
border: '2px dashed #cbd5e1',
borderRadius: '16px',
background: '#f8fafc',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
}}
>
<ImagePlus size={36} />
<strong>Select Photos</strong>
<span
style={{
color: '#64748b',
fontSize: '13px',
}}
>
JPG, PNG or WEBP
</span>
</button>
{selectedFiles.length > 0 && (
<div style={{ marginTop: '20px' }}>
<strong>
{selectedFiles.length} photo
{selectedFiles.length === 1 ? '' : 's'} selected
</strong>
<div
style={{
marginTop: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${index}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#f8fafc',
padding: '10px 12px',
borderRadius: '10px',
}}
>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{file.name}
</span>
<button
type="button"
onClick={() => removeFile(index)}
disabled={uploading}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
}}
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
)}
{error && (
<div
style={{
marginTop: '16px',
padding: '12px',
borderRadius: '10px',
background: '#fff1f2',
color: '#9f1239',
}}
>
{error}
</div>
)}
{success && (
<div
style={{
marginTop: '16px',
padding: '12px',
borderRadius: '10px',
background: '#f0fdf4',
color: '#166534',
}}
>
{success}
</div>
)}
<button
type="button"
disabled={uploading || selectedFiles.length === 0}
onClick={() => void handleUpload()}
style={{
width: '100%',
marginTop: '20px',
padding: '12px 18px',
border: 'none',
borderRadius: '999px',
background:
uploading || selectedFiles.length === 0
? '#cbd5e1'
: '#4f46e5',
color: '#fff',
cursor:
uploading || selectedFiles.length === 0
? 'not-allowed'
: 'pointer',
fontWeight: 700,
}}
>
<Upload
size={16}
style={{
verticalAlign: 'middle',
marginRight: '6px',
}}
/>
{uploading ? 'Uploading...' : 'Upload Photos'}
</button>
</div>
</div>
</div>
);
}