feat: add tags/albums UI

This commit is contained in:
William Valentin
2026-02-02 19:46:24 -08:00
parent e455425d2e
commit eb712ac9e9
4 changed files with 565 additions and 3 deletions

View File

@@ -28,6 +28,8 @@ type SignedUrlResponse = {
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 };
function startOfDayUtc(iso: string) {
const d = new Date(iso);
@@ -57,6 +59,12 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
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 range = useMemo(() => {
if (!props.selectedDayIso) return null;
@@ -174,12 +182,92 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
? "video_mp4"
: playback.variant.kind;
setViewer({ asset, url: playback.url, variant: variantLabel });
void loadAdminLists();
return;
}
const variant: "original" | "thumb_med" | "poster" = "original";
const url = await loadSignedUrl(asset.id, variant);
setViewer({ asset, url, variant });
void loadAdminLists();
}
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 (
@@ -431,6 +519,68 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
<div style={{ color: "#666", fontSize: 12 }}>
{viewer.asset.id}
</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" }}>