807 lines
27 KiB
TypeScript
807 lines
27 KiB
TypeScript
"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<string, string | undefined>;
|
|
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<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" | "video_mp4";
|
|
} | null>(null);
|
|
|
|
const [viewerError, setViewerError] = useState<string | null>(null);
|
|
const [videoFallback, setVideoFallback] = useState<{
|
|
posterUrl: string | null;
|
|
} | null>(null);
|
|
const [retryKey, setRetryKey] = useState(0);
|
|
const [tags, setTags] = useState<Tag[]>([]);
|
|
const [albums, setAlbums] = useState<Album[]>([]);
|
|
const [tagId, setTagId] = useState("");
|
|
const [albumId, setAlbumId] = useState("");
|
|
const [adminError, setAdminError] = useState<string | null>(null);
|
|
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 [dupes, setDupes] = useState<Array<{ id: string }> | null>(null);
|
|
const [dupesError, setDupesError] = useState<string | 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"
|
|
| "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 (
|
|
<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={{
|
|
borderTop: "1px solid #eee",
|
|
paddingTop: 12,
|
|
display: "grid",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<strong style={{ fontSize: 13 }}>Duplicates</strong>
|
|
{dupesError ? (
|
|
<div style={{ color: "#b00", fontSize: 12 }}>
|
|
{dupesError}
|
|
</div>
|
|
) : null}
|
|
{dupes === null ? (
|
|
<div style={{ color: "#666", fontSize: 12 }}>
|
|
Loading...
|
|
</div>
|
|
) : dupes.length === 0 ? (
|
|
<div style={{ color: "#666", fontSize: 12 }}>
|
|
No duplicates.
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gap: 4,
|
|
fontSize: 12,
|
|
color: "#444",
|
|
}}
|
|
>
|
|
<div>{dupes.length} duplicate(s)</div>
|
|
{dupes.map((dupe) => (
|
|
<div key={dupe.id}>{dupe.id.slice(0, 8)}</div>
|
|
))}
|
|
</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
|
|
style={{
|
|
borderTop: "1px solid #eee",
|
|
paddingTop: 12,
|
|
display: "grid",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<strong style={{ fontSize: 13 }}>Tags & Albums</strong>
|
|
{adminError ? (
|
|
<div style={{ color: "#b00", fontSize: 12 }}>
|
|
{adminError}
|
|
</div>
|
|
) : null}
|
|
<div style={{ display: "grid", gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: "#555" }}>
|
|
Assign tag
|
|
</label>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<select
|
|
value={tagId}
|
|
onChange={(e) => setTagId(e.target.value)}
|
|
style={{ flex: 1, padding: 6 }}
|
|
disabled={adminBusy}
|
|
>
|
|
<option value="">Select tag</option>
|
|
{tags.map((tag) => (
|
|
<option key={tag.id} value={tag.id}>
|
|
{tag.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={handleAssignTag}>
|
|
Assign
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "grid", gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: "#555" }}>
|
|
Add to album
|
|
</label>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<select
|
|
value={albumId}
|
|
onChange={(e) => setAlbumId(e.target.value)}
|
|
style={{ flex: 1, padding: 6 }}
|
|
disabled={adminBusy}
|
|
>
|
|
<option value="">Select album</option>
|
|
{albums.map((album) => (
|
|
<option key={album.id} value={album.id}>
|
|
{album.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={handleAddToAlbum}>
|
|
Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div style={{ color: "#b00" }}>
|
|
{viewerError ?? "unknown_error"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|