"use client"; import { useEffect, useMemo, useState } from "react"; type Asset = { id: string; media_type: "image" | "video"; mime_type: string; capture_ts_utc: string | null; thumb_small_key: string | null; thumb_med_key: string | null; poster_key: string | null; status: "new" | "processing" | "ready" | "failed"; error_message: string | null; }; type AssetsResponse = { items: Asset[]; }; type SignedUrlResponse = { url: string; expiresSeconds: number; }; type PreviewUrlState = Record; function startOfDayUtc(iso: string) { const d = new Date(iso); return new Date( Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0), ); } function endOfDayUtc(iso: string) { const start = startOfDayUtc(iso); return new Date(start.getTime() + 24 * 60 * 60 * 1000); } export function MediaPanel(props: { selectedDayIso: string | null }) { const [assets, setAssets] = useState(null); const [error, setError] = useState(null); const [previews, setPreviews] = useState({}); const [viewer, setViewer] = useState<{ asset: Asset; url: string; variant: "original" | "thumb_med" | "poster"; } | null>(null); const [viewerError, setViewerError] = useState(null); const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null; } | null>(null); const [retryKey, setRetryKey] = useState(0); const range = useMemo(() => { if (!props.selectedDayIso) return null; const start = startOfDayUtc(props.selectedDayIso); const end = endOfDayUtc(props.selectedDayIso); return { start, end }; }, [props.selectedDayIso]); useEffect(() => { let cancelled = false; async function load() { if (!range) { setAssets(null); return; } try { setError(null); setAssets(null); const qs = new URLSearchParams({ start: range.start.toISOString(), end: range.end.toISOString(), limit: "120", }); const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store", }); if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`); const json = (await res.json()) as AssetsResponse; if (!cancelled) { setAssets(json.items); setPreviews({}); } } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); } } void load(); return () => { cancelled = true; }; }, [range]); async function loadSignedUrl( assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster", ) { const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, { cache: "no-store", }); if (!res.ok) throw new Error(`presign_failed:${res.status}`); const json = (await res.json()) as SignedUrlResponse; return json.url; } async function openViewer(asset: Asset) { if (asset.status === "failed") { setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`); setViewer(null); return; } setViewerError(null); setVideoFallback(null); const variant: "original" | "thumb_med" | "poster" = "original"; const url = await loadSignedUrl(asset.id, variant); setViewer({ asset, url, variant }); } return (
Media {props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
{error ?
Error: {error}
: null} {!assets && props.selectedDayIso && !error ? (
{[1, 2, 3].map((i) => (
))}
) : null} {assets ? (
{assets.length === 0 ? (
No assets.
) : null} {assets.map((a) => ( ))}
) : null} {viewer || viewerError ? (
{ setViewer(null); setViewerError(null); setVideoFallback(null); }} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "grid", placeItems: "center", padding: 16, }} >
e.stopPropagation()} style={{ width: "min(1000px, 98vw)", maxHeight: "90vh", overflow: "auto", background: "white", borderRadius: 12, padding: 12, display: "grid", gap: 12, }} >
{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}
{viewer ? ( <> {viewer.asset.media_type === "image" ? ( // eslint-disable-next-line @next/next/no-img-element {viewer.asset.id} setViewerError("image_load_failed")} /> ) : (
) : null}
); }