EventBuzz_Website/src/pages/PhotoSearchPage.tsx

473 lines
15 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Search,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import {
getPhotoSearchMeta,
getVisitorLoginPreview,
searchPhotoSearchMatches,
type PhotoSearchMatch,
type PhotoSearchMeta,
type PhotoWallSharePlatforms,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import {
canUseNativeShareSheet,
downloadImageFromUrl,
fetchImageFile,
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
return (
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl bg-white px-6 py-6">{children}</div>
</div>
</div>
);
}
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
{ key: "linkedin", label: "LinkedIn" },
{ key: "facebook", label: "Facebook" },
{ key: "instagram", label: "Instagram" },
{ key: "snapchat", label: "Snapchat" },
{ key: "x", label: "X" },
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
function MatchCard({
match,
eventName,
allowDownload,
showShare,
useNativeShare,
enabledSharePlatforms,
}: {
match: PhotoSearchMatch;
eventName: string;
allowDownload: boolean;
showShare: boolean;
useNativeShare: boolean;
enabledSharePlatforms: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const caption = `Check out my photo from ${eventName}!`;
/** Grid preview uses CDN thumb (`url`) only — never the API file proxy. */
const previewSrc = match.url || undefined;
const downloadSrc = match.fileUrl || match.url || undefined;
useEffect(() => {
if (!match.fileUrl) return;
let cancelled = false;
void fetchImageFile(match.fileUrl, match.originalFileName || "event-photo.jpg")
.then((f) => {
if (!cancelled) setShareFile(f);
})
.catch(() => {
/* ignore — share can still try URL / canvas */
});
return () => {
cancelled = true;
};
}, [match.fileUrl, match.originalFileName]);
const onDownload = async () => {
setActionMsg(null);
try {
if (shareFile) {
const objectUrl = URL.createObjectURL(shareFile);
const a = document.createElement("a");
a.href = objectUrl;
a.download = shareFile.name || "event-photo.jpg";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
setActionMsg("Download started");
return;
}
if (!downloadSrc) return;
await downloadImageFromUrl(downloadSrc, match.originalFileName || "event-photo.jpg");
setActionMsg("Download started");
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Download failed");
}
};
const onNativeShare = async () => {
setActionMsg(null);
try {
const result = await shareViaNativeSheet({
file: shareFile,
imageUrl: downloadSrc,
imgEl: imgRef.current,
title: eventName,
text: caption,
});
if (result === "shared") setActionMsg("Shared");
else setActionMsg(null);
} catch (e) {
const msg = e instanceof Error ? e.message : "Share failed";
setActionMsg(
/load failed|failed to fetch/i.test(msg)
? "Could not share this photo. Try Download, then share from your photos."
: msg,
);
}
};
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
if (!downloadSrc && !shareFile) return;
setActionMsg(null);
try {
const result = await sharePhotoWallImage({
platform,
imageUrl: downloadSrc || "",
file: shareFile,
title: eventName,
text: caption,
});
if (result === "shared") {
setActionMsg(
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
? "Opening app with your photo link"
: "Shared",
);
} else if (result === "cancelled") {
setActionMsg(null);
} else {
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
}
} catch (e) {
setActionMsg(e instanceof Error ? e.message : "Share failed");
}
};
return (
<div className="overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img
ref={imgRef}
crossOrigin="anonymous"
src={previewSrc || undefined}
alt={match.originalFileName || "Matched photo"}
className="mx-auto max-h-[220px] w-full object-contain bg-white"
onError={(e) => {
const img = e.currentTarget;
// Retry without CORS for CDN; do not switch to API file proxy for display.
if (img.crossOrigin) {
img.crossOrigin = null;
if (previewSrc) img.src = previewSrc;
}
}}
/>
<div className="space-y-2 p-3">
{allowDownload ? (
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
) : null}
{showShare ? (
useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<Share2 className="h-3.5 w-3.5" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-1.5">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg border border-[#ddd] bg-white px-2 text-[11px] font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
{item.label}
</button>
))}
</div>
)
) : null}
{actionMsg ? <p className="text-center text-[11px] text-[#666]">{actionMsg}</p> : null}
</div>
</div>
);
}
export function PhotoSearchPage() {
const { searchToken } = useParams<{ searchToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
const [meta, setMeta] = useState<PhotoSearchMeta | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [matches, setMatches] = useState<PhotoSearchMatch[] | null>(null);
const [searched, setSearched] = useState(false);
const load = useCallback(async () => {
if (!searchToken) return;
setLoading(true);
setErr(null);
try {
const data = await getPhotoSearchMeta(searchToken);
setMeta(data);
} catch (e) {
setMeta(null);
setErr(e instanceof Error ? e.message : "Photo search not available");
} finally {
setLoading(false);
}
}, [searchToken]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
);
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
const showShare = enabledSharePlatforms.length > 0;
const allowDownload = meta?.allowDownload !== false;
const clearFile = () => {
setFile(null);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
if (fileRef.current) fileRef.current.value = "";
};
const onPick = (selected: File | null) => {
if (!selected) {
clearFile();
return;
}
if (!selected.type.startsWith("image/")) {
setErr("Please choose an image file");
return;
}
setErr(null);
setMatches(null);
setSearched(false);
setFile(selected);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(selected);
});
};
const onSearch = async () => {
if (!searchToken || !file || !meta) return;
setSearching(true);
setErr(null);
try {
const result = await searchPhotoSearchMatches(searchToken, file);
setMatches(result.matches);
setSearched(true);
} catch (e) {
setMatches(null);
setSearched(false);
setErr(e instanceof Error ? e.message : "Search failed");
} finally {
setSearching(false);
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/photo-search/${searchToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!searchToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">Missing search link</p>
</WhiteCard>
</AuthShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Photo Search"
/>
</div>
) : null}
<h1 className="mb-1 text-center text-[22px] font-semibold text-[#1a1a1a]">Find Photos</h1>
<p className="mb-5 text-center text-sm text-[#666]">
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[#666]">Sign in to search for your photos.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : (
<div className="space-y-4">
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
capture="user"
className="hidden"
onChange={(e) => onPick(e.target.files?.[0] || null)}
/>
{previewUrl ? (
<div className="mb-3 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
) : null}
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
>
{file ? "Change photo" : "Choose photo"}
</button>
</div>
<button
type="button"
disabled={!file || searching}
onClick={() => void onSearch()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
>
<Search className="h-4 w-4" />
{searching ? "Searching…" : "Find photos"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
{searched ? (
<div className="space-y-3 pt-2">
<p className="text-center text-sm font-semibold text-[#333]">
{matches && matches.length > 0
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
: "No matches found"}
</p>
{matches && matches.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{matches.map((m) => (
<MatchCard
key={m._id}
match={m}
eventName={meta?.eventName || "the event"}
allowDownload={allowDownload}
showShare={showShare}
useNativeShare={useNativeShare}
enabledSharePlatforms={enabledSharePlatforms}
/>
))}
</div>
) : null}
</div>
) : null}
</div>
)}
</WhiteCard>
</AuthShell>
);
}