254 lines
7.8 KiB
TypeScript
254 lines
7.8 KiB
TypeScript
"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, 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>
|
|
);
|
|
}
|