Event-photo search and picture wall changes
This commit is contained in:
parent
8e8f506f9a
commit
5e93d5d023
|
|
@ -38,6 +38,7 @@ export default function App() {
|
|||
<Route path="plans" element={<SuperAdminPlansPage />} />
|
||||
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
|
||||
</Route>
|
||||
<Route path=":eventId/searchphoto" element={<ProtectedLayout.EventPhotoSearchShell />} />
|
||||
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { RolesPage } from "./roles/RolesPage";
|
|||
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
|
||||
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
|
||||
import { SearchResultsPage } from "./search/SearchResultsPage";
|
||||
import { EventPhotoSearchPage } from "./events/EventPhotoSearchPage";
|
||||
import { eventsApi } from "../../services/api";
|
||||
import type { Event } from "../../services/api/types";
|
||||
|
||||
|
|
@ -141,6 +142,7 @@ export function MainContent() {
|
|||
if (activeNav === "participants") return <ParticipantsPage />;
|
||||
if (activeNav === "attendance") return <AttendancePage />;
|
||||
if (activeNav === "ticketing") return <EventTicketingPage />;
|
||||
if (activeNav === "photosearch") return <EventPhotoSearchPage />;
|
||||
if (activeNav === "permissions") return <PermissionsPage />;
|
||||
if (activeNav === "event-activities") return <EventActivitiesPage />;
|
||||
if (activeNav === "rewards") return <RewardPointsPage eventId={selectedEventId} />;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const EVENT_NAV_TEXT = "#484B57";
|
|||
const EVENT_NAV_TEXT_MUTED = "rgba(72, 75, 87, 0.72)";
|
||||
|
||||
/** Event sub-routes that keep the event NavBar visible (includes hidden tabs like the website builder). */
|
||||
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz"]);
|
||||
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz", "photosearch"]);
|
||||
|
||||
/* ─────────────────────────────────────
|
||||
NavBar component
|
||||
|
|
@ -181,4 +181,4 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,7 +275,9 @@ export function EventHomeModules() {
|
|||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||
const [pictureWallEnabled, setPictureWallEnabled] = useState(false);
|
||||
const [pictureWallLoading, setPictureWallLoading] = useState(false);
|
||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||
const [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
|
||||
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||
|
|
@ -286,6 +288,22 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
||||
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
|
||||
const [editingExhibitor, setEditingExhibitor] = useState<ApiExhibitor | null>(null);
|
||||
const actionsDisabled = !selectedEventId;
|
||||
const photoSearchHeaderButtonStyle: React.CSSProperties = {
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: "var(--muted)",
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
flexShrink: 0,
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) {
|
||||
|
|
@ -298,6 +316,7 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
setEventName(res.data?.name ?? null);
|
||||
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
||||
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
||||
setPictureWallEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.pictureWallEnabled));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -575,7 +594,6 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
[selectedEventId],
|
||||
);
|
||||
|
||||
const actionsDisabled = !selectedEventId;
|
||||
const pollQuizMeta = !selectedEventId
|
||||
? "Select an event to create polls and quizzes"
|
||||
: pollQuizLoading
|
||||
|
|
@ -630,6 +648,13 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
: webCheckInEnabled
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
const pictureWallMeta = !selectedEventId
|
||||
? "Select an event to configure picture wall"
|
||||
: pictureWallLoading
|
||||
? "Saving…"
|
||||
: pictureWallEnabled
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
|
||||
const handleWebCheckInToggle = async (enabled: boolean) => {
|
||||
if (!selectedEventId) return;
|
||||
|
|
@ -646,6 +671,21 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
}
|
||||
};
|
||||
|
||||
const handlePictureWallToggle = async (enabled: boolean) => {
|
||||
if (!selectedEventId) return;
|
||||
setPictureWallLoading(true);
|
||||
try {
|
||||
const nextSettings = { ...(eventSettings || {}), pictureWallEnabled: enabled };
|
||||
const res = await eventsApi.update(selectedEventId, { settings: nextSettings });
|
||||
if (!res.error) {
|
||||
setEventSettings(nextSettings);
|
||||
setPictureWallEnabled(enabled);
|
||||
}
|
||||
} finally {
|
||||
setPictureWallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePollQuiz = async (
|
||||
kind: "poll" | "quiz",
|
||||
data: { form: QuizPollModalForm; questions: Question[] },
|
||||
|
|
@ -1142,10 +1182,104 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
<ModuleCard
|
||||
icon={<Image size={18} />}
|
||||
title="Picture Wall"
|
||||
meta={pictureWallMeta}
|
||||
description="Enable a public gallery where guests can add and view event photos"
|
||||
headerAction={
|
||||
pictureWallEnabled ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
title="Copy public picture wall link"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...formLinkCopyButtonStyle(false),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!selectedEventId) return;
|
||||
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall/upload`;
|
||||
void navigator.clipboard.writeText(url);
|
||||
}}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Share picture wall"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...formHeaderIconButtonStyle(),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!selectedEventId) return;
|
||||
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall`;
|
||||
if (navigator.share) {
|
||||
void navigator.share({ title: 'Event picture wall', url });
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Share2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
pictureWallEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || pictureWallLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePictureWallToggle(false)}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || pictureWallLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePictureWallToggle(true)}
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ModuleCard
|
||||
icon={<Image size={18} />}
|
||||
title="Event Photo Search"
|
||||
description="Upload event photos and search by image"
|
||||
headerAction={
|
||||
<button
|
||||
type="button"
|
||||
title="Open photo search page"
|
||||
aria-label="Open photo search page"
|
||||
disabled={actionsDisabled}
|
||||
style={photoSearchHeaderButtonStyle}
|
||||
onClick={() => {
|
||||
if (!selectedEventId) return;
|
||||
window.location.href = `https://web.eventbuzz.live/${selectedEventId}/searchphoto`;
|
||||
}}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
</button>
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { eventsApi } from "../../../services/api/events";
|
||||
import type { Event } from "../../../services/api/types";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { buildEventNavPath } from "../../utils/eventNavUrl";
|
||||
import { EventPhotoSearchContent } from "../panels/EventPhotoSearchContent";
|
||||
|
||||
export function EventPhotoSearchPage() {
|
||||
const { eventId = "" } = useParams<{ eventId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { setSelectedEventId } = useAppNav();
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) {
|
||||
setLoading(false);
|
||||
setError("Missing event id.");
|
||||
return;
|
||||
}
|
||||
setSelectedEventId(eventId);
|
||||
}, [eventId, setSelectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void eventsApi.get(eventId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.error || !res.data) {
|
||||
setEvent(null);
|
||||
setError(res.error || "Could not load event.");
|
||||
return;
|
||||
}
|
||||
setEvent(res.data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18, minHeight: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildEventNavPath("dashboard", eventId))}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
padding: 0,
|
||||
marginBottom: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to Event Home
|
||||
</button>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
||||
Event Photo Search
|
||||
</p>
|
||||
<h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: 1.2 }}>
|
||||
{loading ? "Loading…" : event?.name || "Photo Search"}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
color: "var(--muted)",
|
||||
fontSize: "var(--text-sm)",
|
||||
fontFamily: "var(--font-noto-sans)",
|
||||
}}
|
||||
>
|
||||
<Search size={14} />
|
||||
Query photo only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div style={{ padding: "16px 18px", borderRadius: "var(--radius-card)", border: "1px solid color-mix(in srgb, var(--destructive) 30%, var(--border))", background: "var(--card)", color: "var(--destructive)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden", minHeight: "70vh" }}>
|
||||
<EventPhotoSearchContent active={!!eventId} eventId={eventId} mode="search-only" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Image, Search, Trash2, Upload, X } from "lucide-react";
|
||||
import { eventPhotoApi } from "../../../services/api";
|
||||
import type { EventPhotoRow } from "../../../services/api/eventPhoto";
|
||||
|
||||
type EventPhotoSearchMode = "full" | "search-only";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
eventId: string;
|
||||
mode: EventPhotoSearchMode;
|
||||
};
|
||||
|
||||
function formatWhen(iso?: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export function EventPhotoSearchContent({ active, eventId, mode }: Props) {
|
||||
const [loadingPhotos, setLoadingPhotos] = useState(false);
|
||||
const [photos, setPhotos] = useState<EventPhotoRow[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [queryFile, setQueryFile] = useState<File | null>(null);
|
||||
const queryFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchResults, setSearchResults] = useState<EventPhotoRow[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [deletedMsg, setDeletedMsg] = useState<string | null>(null);
|
||||
|
||||
const isFullMode = mode === "full";
|
||||
|
||||
const loadPhotos = async () => {
|
||||
if (!isFullMode) return;
|
||||
setLoadingPhotos(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await eventPhotoApi.listEventPhotos(eventId);
|
||||
setPhotos(r.data?.photos ?? []);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Failed to load photos");
|
||||
setPhotos([]);
|
||||
} finally {
|
||||
setLoadingPhotos(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setErr(null);
|
||||
setDeletedMsg(null);
|
||||
setSearchResults(null);
|
||||
setQueryFile(null);
|
||||
setFiles([]);
|
||||
if (queryFileInputRef.current) queryFileInputRef.current.value = "";
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
if (isFullMode) {
|
||||
void loadPhotos();
|
||||
} else {
|
||||
setPhotos([]);
|
||||
setLoadingPhotos(false);
|
||||
}
|
||||
}, [active, eventId, isFullMode]);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!isFullMode || files.length === 0) return;
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await eventPhotoApi.uploadEventPhoto(eventId, files);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setFiles([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
await loadPhotos();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (photo: EventPhotoRow) => {
|
||||
if (!isFullMode) return;
|
||||
const ok = window.confirm(`Delete "${photo.originalFileName}"?`);
|
||||
if (!ok) return;
|
||||
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await eventPhotoApi.deleteEventPhoto(eventId, photo._id);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setDeletedMsg("Photo deleted.");
|
||||
await loadPhotos();
|
||||
window.setTimeout(() => setDeletedMsg(null), 1800);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const onFindPhotos = async () => {
|
||||
if (!queryFile) return;
|
||||
setSearching(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await eventPhotoApi.searchEventPhotos(eventId, queryFile);
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText || "Search failed";
|
||||
try {
|
||||
const j = await r.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const data = await r.json();
|
||||
setSearchResults(data?.data?.matches ?? []);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "Search failed");
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchResults(null);
|
||||
setQueryFile(null);
|
||||
if (queryFileInputRef.current) queryFileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const displayPhotos = isFullMode ? (searchResults !== null ? searchResults : photos) : searchResults ?? [];
|
||||
const showEmptySearchState = !isFullMode && searchResults === null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
{isFullMode ? (
|
||||
<div style={{ padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: 10 }}>
|
||||
<div style={{ flex: "1 1 280px" }}>
|
||||
<label style={{ display: "block", fontSize: 10, fontFamily: "var(--font-noto-sans)", fontWeight: 700, color: "var(--muted)", textTransform: "uppercase", marginBottom: 6 }}>
|
||||
Upload Photo
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
border: "1px solid var(--primary)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={14} />
|
||||
Choose Photo
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
color: files.length > 0 ? "var(--foreground)" : "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
fontSize: "var(--text-sm)",
|
||||
}}
|
||||
>
|
||||
{files.length === 0 ? "No files selected" : files.map((f) => f.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => setFiles(Array.from(e.target.files || []))}
|
||||
/>
|
||||
<p style={{ marginTop: 8, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||
Supported: JPEG, PNG, JPG
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={files.length === 0 || uploading}
|
||||
onClick={() => void onUpload()}
|
||||
style={{
|
||||
minWidth: 152,
|
||||
height: 44,
|
||||
padding: "0 20px",
|
||||
alignSelf: "flex-start",
|
||||
marginTop: 30,
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: files.length === 0 || uploading ? "#cbd5e1" : "var(--primary)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
cursor: files.length === 0 || uploading ? "not-allowed" : "pointer",
|
||||
transition: "all .2s",
|
||||
}}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload Photo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: isFullMode ? "12px 18px" : "18px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
background: "color-mix(in srgb, var(--primary) 5%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: "1 1 300px" }}>
|
||||
<Search size={18} style={{ color: "var(--primary)" }} />
|
||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 600, color: "var(--foreground)", fontFamily: "var(--font-inter)" }}>
|
||||
{isFullMode ? "Find Photos" : "Query Photo Search"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={queryFileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null;
|
||||
setQueryFile(f);
|
||||
if (f) setSearchResults(null);
|
||||
}}
|
||||
/>
|
||||
{!queryFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryFileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
border: "1px solid var(--primary)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--primary)",
|
||||
color: "var(--primary-foreground)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
fontSize: "var(--text-sm)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Search Photo
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flex: "1 1 auto", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 240 }}>
|
||||
{queryFile.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onFindPhotos()}
|
||||
disabled={searching}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: searching ? "#cbd5e1" : "var(--primary)",
|
||||
color: "#fff",
|
||||
cursor: searching ? "not-allowed" : "pointer",
|
||||
fontWeight: 600,
|
||||
fontSize: "var(--text-sm)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Search size={16} />
|
||||
{searching ? "Searching..." : "Search"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ margin: "8px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||
{isFullMode
|
||||
? "Upload one query photo to find matching event photos."
|
||||
: "Select one photo to search the event photo collection. This page does not add photos to the event."}
|
||||
</p>
|
||||
{searchResults !== null ? (
|
||||
<p style={{ margin: "8px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||
Showing {searchResults.length} result{searchResults.length === 1 ? "" : "s"}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: "var(--text-xs)",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0 0 16px" }}>
|
||||
{err ? (
|
||||
<p style={{ padding: "16px 18px", margin: 0, color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{err}</p>
|
||||
) : null}
|
||||
{deletedMsg ? (
|
||||
<p style={{ padding: "16px 18px", margin: 0, color: "var(--success)", fontSize: "var(--text-sm)" }}>{deletedMsg}</p>
|
||||
) : null}
|
||||
|
||||
{loadingPhotos ? (
|
||||
<p style={{ padding: "24px 18px", margin: 0, color: "var(--muted)" }}>Loading…</p>
|
||||
) : showEmptySearchState ? (
|
||||
<div style={{ padding: "26px 18px" }}>
|
||||
<div style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", padding: "28px 16px", textAlign: "center", background: "var(--card)" }}>
|
||||
<Search size={32} style={{ margin: "0 auto 10px", color: "var(--muted)" }} />
|
||||
<p style={{ margin: 0, fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", color: "var(--foreground)", fontWeight: 700 }}>
|
||||
Search event photos
|
||||
</p>
|
||||
<p style={{ margin: "8px 0 0", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||
Choose one query photo to see matching results for this event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : displayPhotos.length === 0 ? (
|
||||
<div style={{ padding: "26px 18px" }}>
|
||||
<div style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", padding: "22px 16px", textAlign: "center", background: "var(--card)" }}>
|
||||
<Image size={32} style={{ margin: "0 auto 10px", color: "var(--muted)" }} />
|
||||
<p style={{ margin: 0, fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", color: "var(--foreground)", fontWeight: 700 }}>
|
||||
{isFullMode && searchResults === null ? "No photos" : "No matches found"}
|
||||
</p>
|
||||
<p style={{ margin: "8px 0 0", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||
{isFullMode && searchResults === null
|
||||
? "Upload the first photo for this event."
|
||||
: "Try a different query photo for this event."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "12px 18px" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
|
||||
{displayPhotos.map((p) => (
|
||||
<div
|
||||
key={p._id}
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
overflow: "hidden",
|
||||
background: "var(--card)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 140,
|
||||
background: "var(--secondary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{p.url ? (
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.originalFileName}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Image size={32} style={{ color: "var(--muted)" }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "8px 10px" }}>
|
||||
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontWeight: 600, color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{p.originalFileName}
|
||||
</p>
|
||||
<p style={{ margin: "2px 0 0", fontSize: 10, color: "var(--muted)" }}>
|
||||
{formatWhen(p.uploadedAt)}
|
||||
</p>
|
||||
{typeof p.similarity === "number" ? (
|
||||
<p style={{ margin: "4px 0 0", fontSize: 10, color: "var(--primary)", fontWeight: 600 }}>
|
||||
Match {(Math.round(p.similarity * 10) / 10).toFixed(1)}%
|
||||
</p>
|
||||
) : null}
|
||||
<div style={{ marginTop: 6, display: "flex", gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(p.url, "_blank", "noopener,noreferrer")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 8px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--secondary)",
|
||||
color: "var(--foreground)",
|
||||
cursor: "pointer",
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{isFullMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(p)}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-button)",
|
||||
background: "var(--card)",
|
||||
color: "var(--destructive)",
|
||||
cursor: "pointer",
|
||||
fontSize: 10,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Image, Search } from 'lucide-react';
|
||||
import { eventPhotoApi } from '../../../services/api';
|
||||
import type { EventPhotoRow } from '../../../services/api/eventPhoto';
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { EventPhotoSearchContent } from "./EventPhotoSearchContent";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
|
|
@ -10,139 +9,10 @@ type Props = {
|
|||
onClose: () => void;
|
||||
};
|
||||
|
||||
function formatWhen(iso?: string) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function EventPhotoSearchPanel({ open, eventId, eventName, onClose }: Props) {
|
||||
const [loadingPhotos, setLoadingPhotos] = useState(false);
|
||||
const [photos, setPhotos] = useState<EventPhotoRow[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [queryFile, setQueryFile] = useState<File | null>(null);
|
||||
const queryFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchResults, setSearchResults] = useState<EventPhotoRow[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const [deletedMsg, setDeletedMsg] = useState<string | null>(null);
|
||||
|
||||
const loadPhotos = async () => {
|
||||
setLoadingPhotos(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await eventPhotoApi.listEventPhotos(eventId);
|
||||
setPhotos(r.data?.photos ?? []);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed to load photos');
|
||||
setPhotos([]);
|
||||
} finally {
|
||||
setLoadingPhotos(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadPhotos();
|
||||
setSearchResults(null);
|
||||
setQueryFile(null);
|
||||
setFiles([]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, eventId]);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (files.length === 0) return;
|
||||
setUploading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await eventPhotoApi.uploadEventPhoto(eventId, files);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setFiles([]);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
await loadPhotos();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (photo: EventPhotoRow) => {
|
||||
const ok = window.confirm(`Delete "${photo.originalFileName}"?`);
|
||||
if (!ok) return;
|
||||
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await eventPhotoApi.deleteEventPhoto(eventId, photo._id);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
msg = j?.error?.message || msg;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setDeletedMsg('Photo deleted.');
|
||||
await loadPhotos();
|
||||
setTimeout(() => setDeletedMsg(null), 1800);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const onFindPhotos = async () => {
|
||||
if (!queryFile) return;
|
||||
setSearching(true);
|
||||
setErr(null);
|
||||
// For now, just show a placeholder — backend search integration to be added later.
|
||||
// This simulates a search by showing all photos (no actual vector search yet).
|
||||
try {
|
||||
const r = await eventPhotoApi.searchEventPhotos(
|
||||
eventId,
|
||||
queryFile
|
||||
);
|
||||
|
||||
if (!r.ok) {
|
||||
throw new Error("Search failed");
|
||||
}
|
||||
|
||||
const data = await r.json();
|
||||
|
||||
console.log(data);
|
||||
|
||||
setSearchResults(data.data.matches ?? []);
|
||||
|
||||
// setSearchResults(data.matches ?? []);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Search failed');
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const displayPhotos = searchResults !== null ? searchResults : photos;
|
||||
// const displayPhotos = searchResults !== null ? searchResults : photos;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -203,7 +73,7 @@ setSearchResults(data.data.matches ?? []);
|
|||
{eventName || 'Photos'}
|
||||
</h2>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
|
||||
Upload event photos
|
||||
Upload event photos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -229,362 +99,8 @@ setSearchResults(data.data.matches ?? []);
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload Section */}
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div style={{ flex: '1 1 280px' }}>
|
||||
<label style={{ display: 'block', fontSize: 10, fontFamily: 'var(--font-noto-sans)', fontWeight: 700, color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||
Upload Photo
|
||||
</label>
|
||||
{/* <input
|
||||
type="text"
|
||||
placeholder="Enter Document ID (docId)"
|
||||
value={docId}
|
||||
onChange={(e) => setDocId(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontFamily: 'var(--font-noto-sans)',
|
||||
outline: 'none',
|
||||
marginBottom: 8,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--card)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
📁 Choose Photo
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
color: files ? 'var(--foreground)' : 'var(--muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
"No files selected"
|
||||
) : (
|
||||
files.map(f => f.name).join(", ")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
setFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<p style={{ marginTop: 8, fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
|
||||
Supported: JPEG, PNG, JPG
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={files.length === 0 || uploading}
|
||||
onClick={() => void onUpload()}
|
||||
style={{
|
||||
minWidth: 140,
|
||||
height: 44,
|
||||
padding: '0 20px',
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: 30,
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: !files || uploading ? '#cbd5e1' : 'var(--primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
cursor: !files || uploading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all .2s',
|
||||
}}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Photo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Find Photos Section */}
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0, background: 'color-mix(in srgb, var(--primary) 5%, transparent)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 300px' }}>
|
||||
<Search size={18} style={{ color: 'var(--primary)' }} />
|
||||
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--foreground)', fontFamily: 'var(--font-inter)' }}>
|
||||
Find Photos
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={queryFileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null;
|
||||
setQueryFile(f);
|
||||
if (f) {
|
||||
// Auto-trigger search when file is selected
|
||||
setSearchResults(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!queryFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryFileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '10px 18px',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--primary)',
|
||||
color: 'var(--primary-foreground)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'var(--text-sm)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Search size={16} />
|
||||
Find Photos
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: '1 1 auto' }}>
|
||||
<span style={{ fontSize: 'var(--text-sm)', color: 'var(--foreground)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200 }}>
|
||||
{queryFile.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onFindPhotos()}
|
||||
disabled={searching}
|
||||
style={{
|
||||
padding: '10px 18px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: searching ? '#cbd5e1' : 'var(--primary)',
|
||||
color: '#fff',
|
||||
cursor: searching ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'var(--text-sm)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQueryFile(null);
|
||||
setSearchResults(null);
|
||||
if (queryFileInputRef.current) queryFileInputRef.current.value = '';
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{searchResults !== null && (
|
||||
<p style={{ margin: '8px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
|
||||
Showing {searchResults.length} result{searchResults.length === 1 ? '' : 's'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchResults(null);
|
||||
setQueryFile(null);
|
||||
if (queryFileInputRef.current) queryFileInputRef.current.value = '';
|
||||
}}
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
padding: '2px 8px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo List */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 0 16px' }}>
|
||||
{err ? (
|
||||
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--destructive)', fontSize: 'var(--text-sm)' }}>{err}</p>
|
||||
) : null}
|
||||
{deletedMsg ? (
|
||||
<p style={{ padding: '16px 18px', margin: 0, color: 'var(--success)', fontSize: 'var(--text-sm)' }}>{deletedMsg}</p>
|
||||
) : null}
|
||||
|
||||
{loadingPhotos ? (
|
||||
<p style={{ padding: '24px 18px', margin: 0, color: 'var(--muted)' }}>Loading…</p>
|
||||
) : displayPhotos.length === 0 ? (
|
||||
<div style={{ padding: '26px 18px' }}>
|
||||
<div style={{ border: '2px dashed var(--border)', borderRadius: 'var(--radius-card)', padding: '22px 16px', textAlign: 'center' }}>
|
||||
<Image size={32} style={{ margin: '0 auto 10px', color: 'var(--muted)' }} />
|
||||
<p style={{ margin: 0, fontFamily: 'var(--font-inter)', fontSize: 'var(--text-base)', color: 'var(--foreground)', fontWeight: 700 }}>
|
||||
No photos
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 0', fontFamily: 'var(--font-noto-sans)', fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>
|
||||
Upload the first photo for this event.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '12px 18px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
|
||||
{displayPhotos.map((p) => (
|
||||
<div
|
||||
key={p._id}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--card)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 140,
|
||||
background: 'var(--secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{p.url ? (
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.originalFileName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Image size={32} style={{ color: 'var(--muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
<p style={{ margin: 0, fontSize: 'var(--text-xs)', fontWeight: 600, color: 'var(--foreground)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.originalFileName}
|
||||
</p>
|
||||
{/* <p style={{ margin: '4px 0 0', fontSize: 10, color: 'var(--muted)' }}>
|
||||
Document ID: {p._id}
|
||||
</p> */}
|
||||
<p style={{ margin: '2px 0 0', fontSize: 10, color: 'var(--muted)' }}>
|
||||
{formatWhen(p.uploadedAt)}
|
||||
</p>
|
||||
{/* {p.embedding_generated && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: 4,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
background: '#dcfce7',
|
||||
color: '#15803d',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Embedding Ready
|
||||
</span>
|
||||
)} */}
|
||||
<div style={{ marginTop: 6, display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(p.url, '_blank', 'noopener,noreferrer')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--secondary)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(p)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--destructive)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EventPhotoSearchContent active={open} eventId={eventId} mode="full" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,19 +55,21 @@ interface AppNavProviderProps {
|
|||
children: React.ReactNode;
|
||||
/** When set, nav tab comes from the URL and event id is synced via ?event= */
|
||||
activeNavFromUrl?: string;
|
||||
initialSelectedEventId?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV = "home";
|
||||
|
||||
export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderProps) {
|
||||
export function AppNavProvider({ children, activeNavFromUrl, initialSelectedEventId }: AppNavProviderProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const urlRouting = activeNavFromUrl !== undefined;
|
||||
|
||||
const [internalNav, setInternalNav] = useState(DEFAULT_NAV);
|
||||
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() =>
|
||||
urlRouting ? readEventIdFromSearch(window.location.search) : null,
|
||||
);
|
||||
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() => {
|
||||
if (initialSelectedEventId !== undefined) return initialSelectedEventId;
|
||||
return urlRouting ? readEventIdFromSearch(window.location.search) : null;
|
||||
});
|
||||
const selectedEventIdRef = useRef(selectedEventId);
|
||||
selectedEventIdRef.current = selectedEventId;
|
||||
|
||||
|
|
@ -83,10 +85,16 @@ export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderPro
|
|||
|
||||
// Restore selected event from URL on refresh / back-forward.
|
||||
useEffect(() => {
|
||||
if (initialSelectedEventId !== undefined) return;
|
||||
if (!urlRouting) return;
|
||||
const fromUrl = readEventIdFromSearch(location.search);
|
||||
setSelectedEventIdState((prev) => (prev === fromUrl ? prev : fromUrl));
|
||||
}, [location.search, urlRouting]);
|
||||
}, [initialSelectedEventId, location.search, urlRouting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSelectedEventId === undefined) return;
|
||||
setSelectedEventIdState((prev) => (prev === initialSelectedEventId ? prev : initialSelectedEventId));
|
||||
}, [initialSelectedEventId]);
|
||||
|
||||
// Keep ?event= on event-scoped tabs when selection changes in memory.
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -35,3 +35,14 @@ function AppShellWithNav() {
|
|||
}
|
||||
|
||||
ProtectedLayout.AppShellWithNav = AppShellWithNav;
|
||||
|
||||
function EventPhotoSearchShell() {
|
||||
const { eventId } = useParams<{ eventId: string }>();
|
||||
return (
|
||||
<AppNavProvider activeNavFromUrl="photosearch" initialSelectedEventId={eventId || null}>
|
||||
<AppShell />
|
||||
</AppNavProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ProtectedLayout.EventPhotoSearchShell = EventPhotoSearchShell;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue