Add event photo search and picture wall
This commit is contained in:
parent
c9519ac24c
commit
11fc7cc3ce
|
|
@ -11,6 +11,7 @@
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
|
|
@ -2276,6 +2277,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/media-chrome": {
|
||||||
"version": "4.19.2",
|
"version": "4.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { LandingPage } from './pages/LandingPage';
|
||||||
import { EventMicrositePage } from './pages/EventMicrositePage';
|
import { EventMicrositePage } from './pages/EventMicrositePage';
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
import { FormPage } from './pages/FormPage';
|
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';
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -10,9 +13,13 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/event/:eventId/picturewall" element={<PictureWallPage />} />
|
||||||
<Route path="/event/:eventId" element={<EventMicrositePage />} />
|
<Route path="/event/:eventId" element={<EventMicrositePage />} />
|
||||||
<Route path="/event/:eventId/register" element={<RegisterPage />} />
|
<Route path="/event/:eventId/register" element={<RegisterPage />} />
|
||||||
<Route path="/event/:eventId/form/:formId" element={<FormPage />} />
|
<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 />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,73 @@ export const PUBLIC_WEB_BASE =
|
||||||
(import.meta.env.VITE_PUBLIC_WEB_BASE as string | undefined)?.replace(/\/$/, '') ||
|
(import.meta.env.VITE_PUBLIC_WEB_BASE as string | undefined)?.replace(/\/$/, '') ||
|
||||||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5175');
|
(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> = {
|
type ApiEnvelope<T> = {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
data?: T;
|
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}`}`;
|
const url = path.startsWith('http') ? path : `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url);
|
res = await fetch(url, {
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Could not reach the API. If this persists, the server may be down or blocking cross-origin requests.');
|
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 {
|
try {
|
||||||
res = await fetch(url, {
|
res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -72,3 +144,28 @@ export async function apiPost<T>(path: string, payload: unknown): Promise<T> {
|
||||||
}
|
}
|
||||||
return body.data as 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue