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="plans" element={<SuperAdminPlansPage />} />
|
||||||
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
|
<Route path="microsite-templates" element={<SuperAdminMicrositeV2TemplatesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path=":eventId/searchphoto" element={<ProtectedLayout.EventPhotoSearchShell />} />
|
||||||
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
|
<Route path=":navId" element={<ProtectedLayout.AppShellWithNav />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { RolesPage } from "./roles/RolesPage";
|
||||||
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
|
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
|
||||||
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
|
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
|
||||||
import { SearchResultsPage } from "./search/SearchResultsPage";
|
import { SearchResultsPage } from "./search/SearchResultsPage";
|
||||||
|
import { EventPhotoSearchPage } from "./events/EventPhotoSearchPage";
|
||||||
import { eventsApi } from "../../services/api";
|
import { eventsApi } from "../../services/api";
|
||||||
import type { Event } from "../../services/api/types";
|
import type { Event } from "../../services/api/types";
|
||||||
|
|
||||||
|
|
@ -141,6 +142,7 @@ export function MainContent() {
|
||||||
if (activeNav === "participants") return <ParticipantsPage />;
|
if (activeNav === "participants") return <ParticipantsPage />;
|
||||||
if (activeNav === "attendance") return <AttendancePage />;
|
if (activeNav === "attendance") return <AttendancePage />;
|
||||||
if (activeNav === "ticketing") return <EventTicketingPage />;
|
if (activeNav === "ticketing") return <EventTicketingPage />;
|
||||||
|
if (activeNav === "photosearch") return <EventPhotoSearchPage />;
|
||||||
if (activeNav === "permissions") return <PermissionsPage />;
|
if (activeNav === "permissions") return <PermissionsPage />;
|
||||||
if (activeNav === "event-activities") return <EventActivitiesPage />;
|
if (activeNav === "event-activities") return <EventActivitiesPage />;
|
||||||
if (activeNav === "rewards") return <RewardPointsPage eventId={selectedEventId} />;
|
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)";
|
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). */
|
/** 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
|
NavBar component
|
||||||
|
|
@ -181,4 +181,4 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,9 @@ export function EventHomeModules() {
|
||||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = 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 [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
|
||||||
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||||
|
|
@ -286,6 +288,22 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
||||||
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
|
const [showAddExhibitor, setShowAddExhibitor] = useState(false);
|
||||||
const [editingExhibitor, setEditingExhibitor] = useState<ApiExhibitor | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
|
|
@ -298,6 +316,7 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
setEventName(res.data?.name ?? null);
|
setEventName(res.data?.name ?? null);
|
||||||
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
||||||
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
||||||
|
setPictureWallEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.pictureWallEnabled));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -575,7 +594,6 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
[selectedEventId],
|
[selectedEventId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionsDisabled = !selectedEventId;
|
|
||||||
const pollQuizMeta = !selectedEventId
|
const pollQuizMeta = !selectedEventId
|
||||||
? "Select an event to create polls and quizzes"
|
? "Select an event to create polls and quizzes"
|
||||||
: pollQuizLoading
|
: pollQuizLoading
|
||||||
|
|
@ -630,6 +648,13 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
: webCheckInEnabled
|
: webCheckInEnabled
|
||||||
? "Enabled"
|
? "Enabled"
|
||||||
: "Disabled";
|
: "Disabled";
|
||||||
|
const pictureWallMeta = !selectedEventId
|
||||||
|
? "Select an event to configure picture wall"
|
||||||
|
: pictureWallLoading
|
||||||
|
? "Saving…"
|
||||||
|
: pictureWallEnabled
|
||||||
|
? "Enabled"
|
||||||
|
: "Disabled";
|
||||||
|
|
||||||
const handleWebCheckInToggle = async (enabled: boolean) => {
|
const handleWebCheckInToggle = async (enabled: boolean) => {
|
||||||
if (!selectedEventId) return;
|
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 (
|
const handleSavePollQuiz = async (
|
||||||
kind: "poll" | "quiz",
|
kind: "poll" | "quiz",
|
||||||
data: { form: QuizPollModalForm; questions: Question[] },
|
data: { form: QuizPollModalForm; questions: Question[] },
|
||||||
|
|
@ -1142,10 +1182,104 @@ const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<ModuleCard
|
||||||
icon={<Image size={18} />}
|
icon={<Image size={18} />}
|
||||||
title="Event Photo Search"
|
title="Event Photo Search"
|
||||||
description="Upload event photos and search by image"
|
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={
|
actions={
|
||||||
<button
|
<button
|
||||||
type="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 React from "react";
|
||||||
import { X, Trash2, Image, Search } from 'lucide-react';
|
import { X } from "lucide-react";
|
||||||
import { eventPhotoApi } from '../../../services/api';
|
import { EventPhotoSearchContent } from "./EventPhotoSearchContent";
|
||||||
import type { EventPhotoRow } from '../../../services/api/eventPhoto';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -10,139 +9,10 @@ type Props = {
|
||||||
onClose: () => void;
|
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) {
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
const displayPhotos = searchResults !== null ? searchResults : photos;
|
// const displayPhotos = searchResults !== null ? searchResults : photos;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -203,7 +73,7 @@ setSearchResults(data.data.matches ?? []);
|
||||||
{eventName || 'Photos'}
|
{eventName || 'Photos'}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -229,362 +99,8 @@ setSearchResults(data.data.matches ?? []);
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Section */}
|
<EventPhotoSearchContent active={open} eventId={eventId} mode="full" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,19 +55,21 @@ interface AppNavProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** When set, nav tab comes from the URL and event id is synced via ?event= */
|
/** When set, nav tab comes from the URL and event id is synced via ?event= */
|
||||||
activeNavFromUrl?: string;
|
activeNavFromUrl?: string;
|
||||||
|
initialSelectedEventId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_NAV = "home";
|
const DEFAULT_NAV = "home";
|
||||||
|
|
||||||
export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderProps) {
|
export function AppNavProvider({ children, activeNavFromUrl, initialSelectedEventId }: AppNavProviderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const urlRouting = activeNavFromUrl !== undefined;
|
const urlRouting = activeNavFromUrl !== undefined;
|
||||||
|
|
||||||
const [internalNav, setInternalNav] = useState(DEFAULT_NAV);
|
const [internalNav, setInternalNav] = useState(DEFAULT_NAV);
|
||||||
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() =>
|
const [selectedEventId, setSelectedEventIdState] = useState<string | null>(() => {
|
||||||
urlRouting ? readEventIdFromSearch(window.location.search) : null,
|
if (initialSelectedEventId !== undefined) return initialSelectedEventId;
|
||||||
);
|
return urlRouting ? readEventIdFromSearch(window.location.search) : null;
|
||||||
|
});
|
||||||
const selectedEventIdRef = useRef(selectedEventId);
|
const selectedEventIdRef = useRef(selectedEventId);
|
||||||
selectedEventIdRef.current = selectedEventId;
|
selectedEventIdRef.current = selectedEventId;
|
||||||
|
|
||||||
|
|
@ -83,10 +85,16 @@ export function AppNavProvider({ children, activeNavFromUrl }: AppNavProviderPro
|
||||||
|
|
||||||
// Restore selected event from URL on refresh / back-forward.
|
// Restore selected event from URL on refresh / back-forward.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialSelectedEventId !== undefined) return;
|
||||||
if (!urlRouting) return;
|
if (!urlRouting) return;
|
||||||
const fromUrl = readEventIdFromSearch(location.search);
|
const fromUrl = readEventIdFromSearch(location.search);
|
||||||
setSelectedEventIdState((prev) => (prev === fromUrl ? prev : fromUrl));
|
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.
|
// Keep ?event= on event-scoped tabs when selection changes in memory.
|
||||||
useEffect(() => {
|
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. */
|
/** Build app path for a nav tab, attaching ?event= when the tab is event-scoped. */
|
||||||
export function buildEventNavPath(navId: string, eventId?: string | null): string {
|
export function buildEventNavPath(navId: string, eventId?: string | null): string {
|
||||||
|
if (navId === "photosearch" && eventId) {
|
||||||
|
return `/${encodeURIComponent(eventId)}/searchphoto`;
|
||||||
|
}
|
||||||
const path = `/${navId}`;
|
const path = `/${navId}`;
|
||||||
if (eventId && isEventScopedNav(navId)) {
|
if (eventId && isEventScopedNav(navId)) {
|
||||||
return `${path}?${EVENT_QUERY_PARAM}=${encodeURIComponent(eventId)}`;
|
return `${path}?${EVENT_QUERY_PARAM}=${encodeURIComponent(eventId)}`;
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,14 @@ function AppShellWithNav() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtectedLayout.AppShellWithNav = 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 {
|
export interface EventPhotoRow {
|
||||||
_id: string;
|
_id: string;
|
||||||
|
|
@ -17,6 +17,7 @@ export interface EventPhotoRow {
|
||||||
|
|
||||||
embedding_generated: boolean;
|
embedding_generated: boolean;
|
||||||
uploadedAt?: string;
|
uploadedAt?: string;
|
||||||
|
similarity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventPhotoApi = {
|
export const eventPhotoApi = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue