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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user