"use client"; import { useEffect, useMemo, useState } from "react"; import { pickVideoPlaybackVariant } from "../lib/playback"; 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 OverrideResponse = { capture_ts_utc_override: string | null; base_capture_ts_utc: string | null; }; type PreviewUrlState = Record; type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: number }; type VariantsResponse = Array<{ kind: string; size: number; key: string }>; type Tag = { id: string; name: string }; type Album = { id: string; name: string }; type DupesResponse = { items: Array<{ id: string }> }; 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" | "video_mp4"; } | null>(null); const [viewerError, setViewerError] = useState(null); const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null; } | null>(null); const [retryKey, setRetryKey] = useState(0); const [tags, setTags] = useState([]); const [albums, setAlbums] = useState([]); const [tagId, setTagId] = useState(""); const [albumId, setAlbumId] = useState(""); const [adminError, setAdminError] = useState(null); const [adminBusy, setAdminBusy] = useState(false); const [overrideInput, setOverrideInput] = useState(""); const [overrideError, setOverrideError] = useState(null); const [overrideBusy, setOverrideBusy] = useState(false); const [baseCaptureTs, setBaseCaptureTs] = useState(null); const [dupes, setDupes] = useState | null>(null); const [dupesError, setDupesError] = useState(null); 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" | "video_mp4_720", sizeOverride?: number, ) { const url = variant === "video_mp4_720" ? `/api/assets/${assetId}/url?kind=video_mp4&size=${sizeOverride ?? 720}` : `/api/assets/${assetId}/url?variant=${variant}`; const res = await fetch(url, { 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 loadVideoPlaybackUrl( assetId: string, ): Promise<{ url: string; variant: VideoPlaybackVariant }> { try { const res = await fetch(`/api/assets/${assetId}/variants`, { cache: "no-store", }); if (!res.ok) throw new Error(`variants_fetch_failed:${res.status}`); const variants = (await res.json()) as VariantsResponse; const picked = pickVideoPlaybackVariant({ originalMimeType: null, variants: variants .filter((variant) => variant.kind === "video_mp4") .map((variant) => ({ kind: "video_mp4", size: variant.size, key: variant.key, })), }); if (picked?.kind === "video_mp4") { const url = await loadSignedUrl(assetId, "video_mp4_720", picked.size); return { url, variant: { kind: "video_mp4", size: picked.size } }; } } catch { // fall through to original } const url = await loadSignedUrl(assetId, "original"); return { url, variant: { kind: "original" } }; } async function loadDupes(assetId: string) { setDupesError(null); setDupes(null); const res = await fetch(`/api/assets/${assetId}/dupes`, { cache: "no-store" }); if (!res.ok) throw new Error(`dupes_fetch_failed:${res.status}`); const json = (await res.json()) as DupesResponse; setDupes(json.items); } async function openViewer(asset: Asset) { if (asset.status === "failed") { setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`); setViewer(null); return; } setViewerError(null); setVideoFallback(null); try { if (asset.media_type === "video") { const playback = await loadVideoPlaybackUrl(asset.id); const variantLabel = playback.variant.kind === "video_mp4" ? "video_mp4" : playback.variant.kind; setViewer({ asset, url: playback.url, variant: variantLabel }); setBaseCaptureTs(asset.capture_ts_utc); setOverrideInput(asset.capture_ts_utc ?? ""); setOverrideError(null); void loadAdminLists(); void loadDupes(asset.id).catch((err) => { setDupesError(err instanceof Error ? err.message : String(err)); setDupes([]); }); return; } const variant: "original" | "thumb_med" | "poster" = "original"; const url = await loadSignedUrl(asset.id, variant); setViewer({ asset, url, variant }); setBaseCaptureTs(asset.capture_ts_utc); setOverrideInput(asset.capture_ts_utc ?? ""); setOverrideError(null); void loadAdminLists(); void loadDupes(asset.id).catch((err) => { setDupesError(err instanceof Error ? err.message : String(err)); setDupes([]); }); } catch (err) { setViewer(null); setViewerError( err instanceof Error ? err.message : "viewer_open_failed", ); } } async function handleOverrideCaptureTs() { if (!viewer) return; setOverrideError(null); setOverrideBusy(true); try { const token = sessionStorage.getItem("porthole_admin_token") ?? ""; if (!token) throw new Error("missing_admin_token"); const trimmed = overrideInput.trim(); if (!trimmed) throw new Error("enter_iso_timestamp"); const res = await fetch( `/api/assets/${viewer.asset.id}/override-capture-ts`, { method: "POST", headers: { "X-Porthole-Admin-Token": token, "Content-Type": "application/json", }, body: JSON.stringify({ captureTsUtcOverride: trimmed }), }, ); if (!res.ok) throw new Error(`override_failed:${res.status}`); const json = (await res.json()) as OverrideResponse; setViewer((prev) => prev ? { ...prev, asset: { ...prev.asset, capture_ts_utc: json.capture_ts_utc_override ?? json.base_capture_ts_utc, }, } : prev, ); setBaseCaptureTs(json.base_capture_ts_utc ?? null); setOverrideError("Override saved."); } catch (err) { setOverrideError(err instanceof Error ? err.message : String(err)); } finally { setOverrideBusy(false); } } async function handleClearOverride() { if (!viewer) return; setOverrideError(null); setOverrideBusy(true); try { const token = sessionStorage.getItem("porthole_admin_token") ?? ""; if (!token) throw new Error("missing_admin_token"); const res = await fetch( `/api/assets/${viewer.asset.id}/override-capture-ts`, { method: "POST", headers: { "X-Porthole-Admin-Token": token, "Content-Type": "application/json", }, body: JSON.stringify({ captureTsUtcOverride: null }), }, ); if (!res.ok) throw new Error(`override_clear_failed:${res.status}`); const json = (await res.json()) as OverrideResponse; setViewer((prev) => prev ? { ...prev, asset: { ...prev.asset, capture_ts_utc: json.capture_ts_utc_override ?? json.base_capture_ts_utc, }, } : prev, ); setBaseCaptureTs(json.base_capture_ts_utc ?? null); setOverrideInput(json.base_capture_ts_utc ?? ""); setOverrideError("Override cleared."); } catch (err) { setOverrideError(err instanceof Error ? err.message : String(err)); } finally { setOverrideBusy(false); } } async function loadAdminLists() { setAdminError(null); setAdminBusy(true); try { const token = sessionStorage.getItem("porthole_admin_token") ?? ""; if (!token) { setAdminError("Set admin token on /admin first."); return; } const headers = { "X-Porthole-Admin-Token": token }; const [tagsRes, albumsRes] = await Promise.all([ fetch("/api/tags", { headers, cache: "no-store" }), fetch("/api/albums", { headers, cache: "no-store" }), ]); if (!tagsRes.ok) throw new Error(`tags_fetch_failed:${tagsRes.status}`); if (!albumsRes.ok) throw new Error(`albums_fetch_failed:${albumsRes.status}`); const tagsJson = (await tagsRes.json()) as Tag[]; const albumsJson = (await albumsRes.json()) as Album[]; setTags(tagsJson); setAlbums(albumsJson); } catch (err) { setAdminError(err instanceof Error ? err.message : String(err)); } finally { setAdminBusy(false); } } async function handleAssignTag() { if (!viewer) return; setAdminError(null); setAdminBusy(true); try { const token = sessionStorage.getItem("porthole_admin_token") ?? ""; if (!token) throw new Error("missing_admin_token"); if (!tagId) throw new Error("select_tag"); const res = await fetch(`/api/assets/${viewer.asset.id}/tags`, { method: "POST", headers: { "X-Porthole-Admin-Token": token, "Content-Type": "application/json", }, body: JSON.stringify({ tagId }), }); if (!res.ok) throw new Error(`tag_assign_failed:${res.status}`); setAdminError("Tag assigned."); } catch (err) { setAdminError(err instanceof Error ? err.message : String(err)); } finally { setAdminBusy(false); } } async function handleAddToAlbum() { if (!viewer) return; setAdminError(null); setAdminBusy(true); try { const token = sessionStorage.getItem("porthole_admin_token") ?? ""; if (!token) throw new Error("missing_admin_token"); if (!albumId) throw new Error("select_album"); const res = await fetch(`/api/albums/${albumId}/assets`, { method: "POST", headers: { "X-Porthole-Admin-Token": token, "Content-Type": "application/json", }, body: JSON.stringify({ assetId: viewer.asset.id }), }); if (!res.ok) throw new Error(`album_add_failed:${res.status}`); setAdminError("Added to album."); } catch (err) { setAdminError(err instanceof Error ? err.message : String(err)); } finally { setAdminBusy(false); } } 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}
); }