feat: add UI for capture time override
This commit is contained in:
@@ -65,6 +65,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
const [albumId, setAlbumId] = useState("");
|
const [albumId, setAlbumId] = useState("");
|
||||||
const [adminError, setAdminError] = useState<string | null>(null);
|
const [adminError, setAdminError] = useState<string | null>(null);
|
||||||
const [adminBusy, setAdminBusy] = useState(false);
|
const [adminBusy, setAdminBusy] = useState(false);
|
||||||
|
const [overrideInput, setOverrideInput] = useState("");
|
||||||
|
const [overrideError, setOverrideError] = useState<string | null>(null);
|
||||||
|
const [overrideBusy, setOverrideBusy] = useState(false);
|
||||||
|
const [baseCaptureTs, setBaseCaptureTs] = useState<string | null>(null);
|
||||||
|
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
if (!props.selectedDayIso) return null;
|
if (!props.selectedDayIso) return null;
|
||||||
@@ -183,6 +187,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
? "video_mp4"
|
? "video_mp4"
|
||||||
: playback.variant.kind;
|
: playback.variant.kind;
|
||||||
setViewer({ asset, url: playback.url, variant: variantLabel });
|
setViewer({ asset, url: playback.url, variant: variantLabel });
|
||||||
|
setBaseCaptureTs(asset.capture_ts_utc);
|
||||||
|
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||||
|
setOverrideError(null);
|
||||||
void loadAdminLists();
|
void loadAdminLists();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,6 +197,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||||
const url = await loadSignedUrl(asset.id, variant);
|
const url = await loadSignedUrl(asset.id, variant);
|
||||||
setViewer({ asset, url, variant });
|
setViewer({ asset, url, variant });
|
||||||
|
setBaseCaptureTs(asset.capture_ts_utc);
|
||||||
|
setOverrideInput(asset.capture_ts_utc ?? "");
|
||||||
|
setOverrideError(null);
|
||||||
void loadAdminLists();
|
void loadAdminLists();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setViewer(null);
|
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() {
|
async function loadAdminLists() {
|
||||||
setAdminError(null);
|
setAdminError(null);
|
||||||
setAdminBusy(true);
|
setAdminBusy(true);
|
||||||
@@ -527,6 +618,49 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
|||||||
{viewer.asset.id}
|
{viewer.asset.id}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid #eee",
|
||||||
|
paddingTop: 12,
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: 13 }}>Capture time override</strong>
|
||||||
|
<div style={{ fontSize: 12, color: "#555" }}>
|
||||||
|
Effective: {viewer.asset.capture_ts_utc ?? "(unset)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#555" }}>
|
||||||
|
Base: {baseCaptureTs ?? "(unknown)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gap: 6 }}>
|
||||||
|
<label style={{ fontSize: 12, color: "#555" }}>
|
||||||
|
ISO timestamp
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="2026-02-01T12:34:56.000Z"
|
||||||
|
value={overrideInput}
|
||||||
|
onChange={(e) => setOverrideInput(e.target.value)}
|
||||||
|
style={{ padding: 6, borderRadius: 6, border: "1px solid #ccc" }}
|
||||||
|
disabled={overrideBusy}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button type="button" onClick={handleOverrideCaptureTs}>
|
||||||
|
Save override
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleClearOverride}>
|
||||||
|
Clear override
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{overrideError ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#b00" }}>
|
||||||
|
{overrideError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid #eee",
|
borderTop: "1px solid #eee",
|
||||||
|
|||||||
138
docs/plans/2026-02-03-capture-time-override-ui.md
Normal file
138
docs/plans/2026-02-03-capture-time-override-ui.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Capture Time Override UI Implementation Plan
|
||||||
|
|
||||||
|
> **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<string | null>(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
|
||||||
|
<div style={{ display: "grid", gap: 6 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>Capture time override</strong>
|
||||||
|
<div style={{ color: "#666", fontSize: 12 }}>
|
||||||
|
Effective: {effectiveTs ?? "(none)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#999", fontSize: 12 }}>
|
||||||
|
Base: {baseTs ?? "(unknown)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="2026-01-01T00:00:00.000Z"
|
||||||
|
value={captureOverrideInput}
|
||||||
|
onChange={(e) => setCaptureOverrideInput(e.target.value)}
|
||||||
|
style={{ flex: 1, padding: 6 }}
|
||||||
|
disabled={captureOverrideBusy}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleOverrideCaptureTs}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{captureOverrideError ? (
|
||||||
|
<div style={{ color: "#b00", fontSize: 12 }}>{captureOverrideError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user