From eb712ac9e97cda45adeb79c1acd9f0a7ae2d0588 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 19:46:24 -0800 Subject: [PATCH] feat: add tags/albums UI --- apps/web/app/admin/page.tsx | 251 ++++++++++++++++++++- apps/web/app/api/assets/[id]/tags/route.ts | 78 +++++++ apps/web/app/components/MediaPanel.tsx | 150 ++++++++++++ docs/plans/2026-02-02-tags-albums-ui.md | 89 ++++++++ 4 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/api/assets/[id]/tags/route.ts create mode 100644 docs/plans/2026-02-02-tags-albums-ui.md diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index c65372a..d3c83f8 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -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(null); + + const [tags, setTags] = useState([]); + const [tagsError, setTagsError] = useState(null); + const [tagsLoading, setTagsLoading] = useState(false); + const [newTag, setNewTag] = useState(""); + + const [albums, setAlbums] = useState([]); + const [albumsError, setAlbumsError] = useState(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 ( -
-

Admin

-

Upload + scan tools will live here.

+
+
+

Admin

+

+ Manage tags and albums. Admin token is stored in sessionStorage. +

+
+ +
+

Admin Token

+
+ setTokenInput(e.target.value)} + style={{ padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> +
+ + +
+ {tokenMessage ? ( +
{tokenMessage}
+ ) : null} +
+
+ +
+

Tags

+
+ setNewTag(e.target.value)} + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + + +
+ {tagsError ? ( +
{tagsError}
+ ) : null} +
    + {tags.length === 0 ? ( +
  • No tags yet.
  • + ) : ( + tags.map((tag) =>
  • {tag.name}
  • ) + )} +
+
+ +
+

Albums

+
+ setNewAlbum(e.target.value)} + style={{ flex: 1, padding: 8, borderRadius: 6, border: "1px solid #ccc" }} + /> + + +
+ {albumsError ? ( +
{albumsError}
+ ) : null} +
    + {albums.length === 0 ? ( +
  • No albums yet.
  • + ) : ( + albums.map((album) =>
  • {album.name}
  • ) + )} +
+
); } diff --git a/apps/web/app/api/assets/[id]/tags/route.ts b/apps/web/app/api/assets/[id]/tags/route.ts new file mode 100644 index 0000000..ac51a00 --- /dev/null +++ b/apps/web/app/api/assets/[id]/tags/route.ts @@ -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 { + 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 }); +} diff --git a/apps/web/app/components/MediaPanel.tsx b/apps/web/app/components/MediaPanel.tsx index 9897c13..b309b93 100644 --- a/apps/web/app/components/MediaPanel.tsx +++ b/apps/web/app/components/MediaPanel.tsx @@ -28,6 +28,8 @@ type SignedUrlResponse = { type PreviewUrlState = Record; 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([]); + const [albums, setAlbums] = useState([]); + const [tagId, setTagId] = useState(""); + const [albumId, setAlbumId] = useState(""); + const [adminError, setAdminError] = useState(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 }) {
{viewer.asset.id}
+ +
+ Tags & Albums + {adminError ? ( +
+ {adminError} +
+ ) : null} +
+ +
+ + +
+
+
+ +
+ + +
+
+
) : (
diff --git a/docs/plans/2026-02-02-tags-albums-ui.md b/docs/plans/2026-02-02-tags-albums-ui.md new file mode 100644 index 0000000..88dbb59 --- /dev/null +++ b/docs/plans/2026-02-02-tags-albums-ui.md @@ -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" +```