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

@@ -1,8 +1,253 @@
"use client";
import { useEffect, useMemo, useState } from "react";
const ADMIN_TOKEN_KEY = "porthole_admin_token";
type Tag = {
id: string;
name: string;
created_at: string;
};
type Album = {
id: string;
name: string;
created_at: string;
};
export default function AdminPage() {
const [token, setToken] = useState("");
const [tokenInput, setTokenInput] = useState("");
const [tokenMessage, setTokenMessage] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [tagsError, setTagsError] = useState<string | null>(null);
const [tagsLoading, setTagsLoading] = useState(false);
const [newTag, setNewTag] = useState("");
const [albums, setAlbums] = useState<Album[]>([]);
const [albumsError, setAlbumsError] = useState<string | null>(null);
const [albumsLoading, setAlbumsLoading] = useState(false);
const [newAlbum, setNewAlbum] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
const stored = sessionStorage.getItem(ADMIN_TOKEN_KEY) ?? "";
setToken(stored);
setTokenInput(stored);
}, []);
const adminHeaders = useMemo(() => {
if (!token) return null;
return { "X-Porthole-Admin-Token": token };
}, [token]);
async function loadTags() {
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
setTagsLoading(true);
setTagsError(null);
try {
const res = await fetch("/api/tags", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`tags_fetch_failed:${res.status}`);
const json = (await res.json()) as Tag[];
setTags(json);
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
} finally {
setTagsLoading(false);
}
}
async function loadAlbums() {
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
setAlbumsLoading(true);
setAlbumsError(null);
try {
const res = await fetch("/api/albums", {
headers: adminHeaders,
cache: "no-store",
});
if (!res.ok) throw new Error(`albums_fetch_failed:${res.status}`);
const json = (await res.json()) as Album[];
setAlbums(json);
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
} finally {
setAlbumsLoading(false);
}
}
async function handleSaveToken(event: React.FormEvent) {
event.preventDefault();
if (typeof window === "undefined") return;
const trimmed = tokenInput.trim();
if (trimmed) {
sessionStorage.setItem(ADMIN_TOKEN_KEY, trimmed);
setToken(trimmed);
setTokenMessage("Token saved for this session.");
} else {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
setToken("");
setTokenMessage("Token cleared.");
}
}
async function handleCreateTag(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setTagsError("Set admin token first.");
return;
}
if (!newTag.trim()) {
setTagsError("Tag name is required.");
return;
}
try {
setTagsError(null);
const res = await fetch("/api/tags", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newTag.trim() }),
});
if (!res.ok) throw new Error(`tag_create_failed:${res.status}`);
setNewTag("");
await loadTags();
} catch (err) {
setTagsError(err instanceof Error ? err.message : String(err));
}
}
async function handleCreateAlbum(event: React.FormEvent) {
event.preventDefault();
if (!adminHeaders) {
setAlbumsError("Set admin token first.");
return;
}
if (!newAlbum.trim()) {
setAlbumsError("Album name is required.");
return;
}
try {
setAlbumsError(null);
const res = await fetch("/api/albums", {
method: "POST",
headers: { ...adminHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ name: newAlbum.trim() }),
});
if (!res.ok) throw new Error(`album_create_failed:${res.status}`);
setNewAlbum("");
await loadAlbums();
} catch (err) {
setAlbumsError(err instanceof Error ? err.message : String(err));
}
}
return (
<main style={{ padding: 16 }}>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p>Upload + scan tools will live here.</p>
<main style={{ padding: 16, display: "grid", gap: 20, maxWidth: 720 }}>
<header>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p style={{ color: "#555" }}>
Manage tags and albums. Admin token is stored in sessionStorage.
</p>
</header>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Admin Token</h2>
<form onSubmit={handleSaveToken} style={{ display: "grid", gap: 8 }}>
<input
type="password"
placeholder="X-Porthole-Admin-Token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit">Save token</button>
<button
type="button"
onClick={() => {
setTokenInput("");
setToken("");
if (typeof window !== "undefined") {
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
}
setTokenMessage("Token cleared.");
}}
>
Clear
</button>
</div>
{tokenMessage ? (
<div style={{ fontSize: 12, color: "#444" }}>{tokenMessage}</div>
) : null}
</form>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Tags</h2>
<form onSubmit={handleCreateTag} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New tag name"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadTags} disabled={tagsLoading}>
{tagsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{tagsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{tagsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{tags.length === 0 ? (
<li style={{ color: "#666" }}>No tags yet.</li>
) : (
tags.map((tag) => <li key={tag.id}>{tag.name}</li>)
)}
</ul>
</section>
<section style={{ border: "1px solid #ddd", borderRadius: 12, padding: 16 }}>
<h2 style={{ marginTop: 0 }}>Albums</h2>
<form onSubmit={handleCreateAlbum} style={{ display: "flex", gap: 8 }}>
<input
type="text"
placeholder="New album name"
value={newAlbum}
onChange={(e) => setNewAlbum(e.target.value)}
style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }}
/>
<button type="submit">Create</button>
<button type="button" onClick={loadAlbums} disabled={albumsLoading}>
{albumsLoading ? "Loading..." : "Refresh"}
</button>
</form>
{albumsError ? (
<div style={{ color: "#b00", marginTop: 8 }}>{albumsError}</div>
) : null}
<ul style={{ marginTop: 12, paddingLeft: 16 }}>
{albums.length === 0 ? (
<li style={{ color: "#666" }}>No albums yet.</li>
) : (
albums.map((album) => <li key={album.id}>{album.name}</li>)
)}
</ul>
</section>
</main>
);
}

View File

@@ -0,0 +1,78 @@
import { getAdminToken, isAdminRequest } from "@tline/config";
import { getDb } from "@tline/db";
import { z } from "zod";
export const runtime = "nodejs";
const ADMIN_HEADER = "X-Porthole-Admin-Token";
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z
.object({
tagId: z.string().uuid(),
})
.strict();
function getAdminOk(headers: Headers) {
const headerToken = headers.get(ADMIN_HEADER);
return isAdminRequest({ adminToken: getAdminToken() }, { headerToken });
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
if (!getAdminOk(request.headers)) {
return Response.json({ error: "admin_required" }, { status: 401 });
}
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const bodyJson = await request.json().catch(() => ({}));
const bodyParsed = bodySchema.safeParse(bodyJson);
if (!bodyParsed.success) {
return Response.json(
{ error: "invalid_body", issues: bodyParsed.error.issues },
{ status: 400 },
);
}
const db = getDb();
const rows = await db<
{
asset_id: string;
tag_id: string;
}[]
>`
insert into asset_tags (asset_id, tag_id)
values (${paramsParsed.data.id}, ${bodyParsed.data.tagId})
on conflict (asset_id, tag_id)
do nothing
returning asset_id, tag_id
`;
const created =
rows[0] ??
({ asset_id: paramsParsed.data.id, tag_id: bodyParsed.data.tagId } as const);
const payload = JSON.stringify({
asset_id: created.asset_id,
tag_id: created.tag_id,
});
await db`
insert into audit_log (actor, action, entity_type, entity_id, payload)
values ('admin', 'add_tag', 'asset', ${created.asset_id}, ${payload}::jsonb)
`;
return Response.json(created, { status: 200 });
}

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" }}>

View File

@@ -0,0 +1,89 @@
# Tags/Albums UI Wiring Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add minimal admin UI to manage tags/albums and wire asset detail UI to assign tags and add assets to albums.
**Architecture:** Keep UI changes local to existing Next.js components. Use lightweight fetch calls to existing `/api/tags` and `/api/albums` endpoints with admin header set from sessionStorage, plus inline error handling. Avoid new state management or styling systems.
**Tech Stack:** Next.js app router, React, TypeScript, fetch API, inline styles/Tailwind classes.
### Task 1: Establish admin token input + list/create tags/albums UI
**Files:**
- Modify: `apps/web/app/admin/page.tsx`
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Write minimal implementation**
- Convert page to client component.
- Add admin token form that reads/writes `sessionStorage`.
- Add list + create for tags and albums using `fetch` with `X-Porthole-Admin-Token` header.
- Inline errors per section.
**Step 4: Run test to verify it passes**
Skipped.
**Step 5: Commit**
Include with other tasks once all UI wiring is complete.
### Task 2: Asset detail UI for tag assignment and album add
**Files:**
- Modify: `apps/web/app/components/MediaPanel.tsx`
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Write minimal implementation**
- Add UI section in viewer panel to assign tag(s) to the current asset and add asset to album.
- Fetch tags/albums lists using admin token from `sessionStorage`.
- Use inline error handling and disable actions when missing token/asset.
**Step 4: Run test to verify it passes**
Skipped.
**Step 5: Commit**
Include with other tasks once all UI wiring is complete.
### Task 3: Validate behavior manually
**Files:**
- None
**Step 1: Write the failing test**
No tests for UI wiring are added per user-approved TDD exception. Record rationale in implementation notes.
**Step 2: Run test to verify it fails**
Skipped.
**Step 3: Manual smoke**
- Load `/admin` page, set token, create tag/album, verify list refresh.
- Open asset viewer in media panel, assign tag/add to album, confirm inline success/error states.
**Step 4: Commit**
```bash
git add apps/web/app/admin/page.tsx apps/web/app/components/MediaPanel.tsx docs/plans/2026-02-02-tags-albums-ui.md
git commit -m "feat: add tags/albums UI"
```