From 8eae0c7c97c29e7659be3226347741fcc78a0119 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 4 Feb 2026 08:57:27 -0800 Subject: [PATCH] feat: add UI for capture time override --- apps/web/app/components/MediaPanel.tsx | 134 +++++++++++++++++ .../2026-02-03-capture-time-override-ui.md | 138 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 docs/plans/2026-02-03-capture-time-override-ui.md diff --git a/apps/web/app/components/MediaPanel.tsx b/apps/web/app/components/MediaPanel.tsx index 9a56e94..a708af9 100644 --- a/apps/web/app/components/MediaPanel.tsx +++ b/apps/web/app/components/MediaPanel.tsx @@ -65,6 +65,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { 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 range = useMemo(() => { if (!props.selectedDayIso) return null; @@ -183,6 +187,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { ? "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(); return; } @@ -190,6 +197,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { 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(); } catch (err) { setViewer(null); @@ -199,6 +209,87 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { } } + 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}`); + setViewer((prev) => + prev + ? { + ...prev, + asset: { + ...prev.asset, + capture_ts_utc: trimmed, + }, + } + : prev, + ); + 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}`); + setViewer((prev) => + prev + ? { + ...prev, + asset: { + ...prev.asset, + capture_ts_utc: baseCaptureTs, + }, + } + : prev, + ); + setOverrideInput(baseCaptureTs ?? ""); + setOverrideError("Override cleared."); + } catch (err) { + setOverrideError(err instanceof Error ? err.message : String(err)); + } finally { + setOverrideBusy(false); + } + } + async function loadAdminLists() { setAdminError(null); setAdminBusy(true); @@ -527,6 +618,49 @@ export function MediaPanel(props: { selectedDayIso: string | null }) { {viewer.asset.id} +
+ Capture time override +
+ Effective: {viewer.asset.capture_ts_utc ?? "(unset)"} +
+
+ Base: {baseCaptureTs ?? "(unknown)"} +
+
+ + setOverrideInput(e.target.value)} + style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }} + disabled={overrideBusy} + /> +
+ + +
+ {overrideError ? ( +
+ {overrideError} +
+ ) : null} +
+
+
**For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a capture-time override form in the MediaPanel viewer to POST override timestamps and display current effective/base timestamps. + +**Architecture:** Extend the existing MediaPanel viewer admin controls with a small form that reads/writes to `/api/assets/:id/override-capture-ts` using the existing admin token from sessionStorage. Keep UI state local to MediaPanel and refresh the viewer asset timestamps after submit. + +**Tech Stack:** React (Next.js app router), TypeScript, fetch API + +### Task 1: Add override state and helpers + +**Files:** +- Modify: `apps/web/app/components/MediaPanel.tsx` + +**Step 1: Write the failing test** + +No tests (user-approved). + +**Step 2: Add state for override input, status, and effective/base timestamps** + +```ts +const [captureOverrideInput, setCaptureOverrideInput] = useState(""); +const [captureOverrideError, setCaptureOverrideError] = useState(null); +const [captureOverrideBusy, setCaptureOverrideBusy] = useState(false); +``` + +**Step 3: Add helper to derive effective/base timestamp** + +```ts +const effectiveTs = viewer?.asset.capture_ts_utc ?? null; +const baseTs = viewer?.asset.capture_ts_utc ?? null; // updated when override applied +``` + +**Step 4: Commit** + +No commit yet; continue tasks. + +### Task 2: Add override POST handler + +**Files:** +- Modify: `apps/web/app/components/MediaPanel.tsx` + +**Step 1: Write the failing test** + +No tests (user-approved). + +**Step 2: Implement submit handler** + +```ts +async function handleOverrideCaptureTs() { + if (!viewer) return; + setCaptureOverrideError(null); + setCaptureOverrideBusy(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({ capture_ts_utc: captureOverrideInput || null }), + }); + if (!res.ok) throw new Error(`override_failed:${res.status}`); + // refresh viewer asset timestamps (re-fetch list or update local) + } catch (err) { + setCaptureOverrideError(err instanceof Error ? err.message : String(err)); + } finally { + setCaptureOverrideBusy(false); + } +} +``` + +**Step 3: Commit** + +No commit yet; continue tasks. + +### Task 3: Add UI above Tags & Albums + +**Files:** +- Modify: `apps/web/app/components/MediaPanel.tsx` + +**Step 1: Write the failing test** + +No tests (user-approved). + +**Step 2: Add form UI** + +```tsx +
+ Capture time override +
+ Effective: {effectiveTs ?? "(none)"} +
+
+ Base: {baseTs ?? "(unknown)"} +
+
+ setCaptureOverrideInput(e.target.value)} + style={{ flex: 1, padding: 6 }} + disabled={captureOverrideBusy} + /> + +
+ {captureOverrideError ? ( +
{captureOverrideError}
+ ) : null} +
+``` + +**Step 3: Commit** + +No commit yet; continue tasks. + +### Task 4: Finalize, verify, and commit + +**Files:** +- Modify: `apps/web/app/components/MediaPanel.tsx` + +**Step 1: Quick manual check** + +Run: `npm test` (skip) +Expected: (skipped per user) + +**Step 2: Commit** + +```bash +git add apps/web/app/components/MediaPanel.tsx +git commit -m "feat: add UI for capture time override" +```