292 lines
9.4 KiB
TypeScript
292 lines
9.4 KiB
TypeScript
"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<string, string | undefined>;
|
|
|
|
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<Asset[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [previews, setPreviews] = useState<PreviewUrlState>({});
|
|
|
|
const [viewer, setViewer] = useState<{
|
|
asset: Asset;
|
|
url: string;
|
|
variant: "original" | "thumb_med" | "poster";
|
|
} | null>(null);
|
|
|
|
const [viewerError, setViewerError] = useState<string | null>(null);
|
|
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(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") {
|
|
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 (
|
|
<div style={{ display: "grid", gap: 12 }}>
|
|
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
|
<strong>Media</strong>
|
|
<span style={{ color: "#666", fontSize: 12 }}>
|
|
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
|
|
</span>
|
|
</div>
|
|
|
|
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
|
{!assets && props.selectedDayIso && !error ? (
|
|
<div style={{ color: "#666" }}>Loading assets…</div>
|
|
) : null}
|
|
|
|
{assets ? (
|
|
<div style={{ display: "grid", gap: 8 }}>
|
|
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
|
{assets.map((a) => (
|
|
<button
|
|
key={a.id}
|
|
type="button"
|
|
onClick={() => void openViewer(a)}
|
|
onPointerEnter={() => {
|
|
if (previews[a.id] !== undefined) return;
|
|
|
|
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
|
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
|
|
|
void promise.then((url) => {
|
|
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
|
});
|
|
}}
|
|
style={{
|
|
textAlign: "left",
|
|
padding: 10,
|
|
border: "1px solid #ddd",
|
|
borderRadius: 8,
|
|
background: "white",
|
|
}}
|
|
>
|
|
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
|
<div
|
|
style={{
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 8,
|
|
background: "#f2f2f2",
|
|
border: "1px solid #eee",
|
|
overflow: "hidden",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: "#888",
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{previews[a.id] ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
) : (
|
|
<span>{a.media_type}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
|
<span>
|
|
{a.media_type} · {a.status}
|
|
</span>
|
|
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
|
|
</div>
|
|
{a.status === "failed" && a.error_message ? (
|
|
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{viewer || viewerError ? (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={() => {
|
|
setViewer(null);
|
|
setViewerError(null);
|
|
setVideoFallback(null);
|
|
}}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.6)",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
padding: 16,
|
|
}}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: "min(1000px, 98vw)",
|
|
maxHeight: "90vh",
|
|
overflow: "auto",
|
|
background: "white",
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
display: "grid",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
|
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setViewer(null);
|
|
setViewerError(null);
|
|
setVideoFallback(null);
|
|
}}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
{viewer ? (
|
|
<>
|
|
{viewer.asset.media_type === "image" ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={viewer.url}
|
|
alt={viewer.asset.id}
|
|
style={{ width: "100%", height: "auto" }}
|
|
onError={() => setViewerError("image_load_failed")}
|
|
/>
|
|
) : (
|
|
<video
|
|
src={viewer.url}
|
|
controls
|
|
style={{ width: "100%" }}
|
|
poster={videoFallback?.posterUrl ?? undefined}
|
|
onError={() => {
|
|
setViewerError("video_playback_failed");
|
|
if (videoFallback !== null) return;
|
|
setVideoFallback({ posterUrl: null });
|
|
void loadSignedUrl(viewer.asset.id, "poster")
|
|
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
|
.catch(() => setVideoFallback({ posterUrl: null }));
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{viewerError ? (
|
|
<div style={{ color: "#b00", fontSize: 12 }}>
|
|
{viewerError}
|
|
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
|
</>
|
|
) : (
|
|
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|