Files
porthole/apps/web/app/components/MediaPanel.tsx
OpenCode Test 4e2ab7cdd8 task-11: complete QA + hardening with resilience fixes
- Created comprehensive QA checklist covering edge cases (missing EXIF, timezones, codecs, corrupt files)
- Added ErrorBoundary component wrapped around TimelineTree and MediaPanel
- Created global error.tsx page for unhandled errors
- Improved failed asset UX with red borders, warning icons, and inline error display
- Added loading skeletons to TimelineTree and MediaPanel
- Added retry button for failed media loads
- Created DEPLOYMENT_VALIDATION.md with validation commands and checklist
- Applied k8s recommendations:
  - Changed node affinity to required for compute nodes (Pi 5)
  - Enabled Tailscale LoadBalancer service for MinIO S3 (reliable Range requests)
  - Enabled cleanup CronJob for staging files
2025-12-24 12:45:22 -08:00

392 lines
12 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 [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 (
<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 className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-2.5 rounded-lg border border-gray-200 bg-white p-2.5"
>
<div
className="h-18 w-18 animate-pulse rounded-lg bg-gray-200"
style={{ width: 72, height: 72 }}
/>
<div className="flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
<div className="h-3 w-20 animate-pulse rounded bg-gray-200" />
</div>
</div>
))}
</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:
a.status === "failed"
? "2px solid #ef4444"
: "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: a.status === "failed" ? "#fef2f2" : "#f2f2f2",
border: "1px solid #eee",
overflow: "hidden",
display: "grid",
placeItems: "center",
color: "#888",
fontSize: 12,
}}
>
{a.status === "failed" ? (
<span style={{ fontSize: 24, color: "#ef4444" }}></span>
) : 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: "#ef4444", 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
key={retryKey}
src={viewer.url}
alt={viewer.asset.id}
style={{ width: "100%", height: "auto" }}
onError={() => setViewerError("image_load_failed")}
/>
) : (
<video
key={retryKey}
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 className="flex items-center justify-between gap-4 rounded border border-red-200 bg-red-50 p-3">
<div style={{ color: "#b00", fontSize: 12 }}>
{viewerError}
{viewer.asset.media_type === "video"
? " (try a different browser/codec)"
: null}
</div>
<button
type="button"
onClick={() => {
setViewerError(null);
setRetryKey((k) => k + 1);
}}
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
>
Retry
</button>
</div>
) : null}
<div style={{ color: "#666", fontSize: 12 }}>
{viewer.asset.id}
</div>
</>
) : (
<div style={{ color: "#b00" }}>
{viewerError ?? "unknown_error"}
</div>
)}
</div>
</div>
) : null}
</div>
);
}