feat: add tags/albums UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
78
apps/web/app/api/assets/[id]/tags/route.ts
Normal file
78
apps/web/app/api/assets/[id]/tags/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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" }}>
|
||||
|
||||
89
docs/plans/2026-02-02-tags-albums-ui.md
Normal file
89
docs/plans/2026-02-02-tags-albums-ui.md
Normal 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"
|
||||
```
|
||||
Reference in New Issue
Block a user