Add edit flow for portfolio views in admin

Load existing views into the form to change name, slug, description,
category priority order, show-others, and active flags.
This commit is contained in:
2026-07-24 11:47:46 -04:00
parent ff37533037
commit a575040217
+166 -31
View File
@@ -1,14 +1,22 @@
import { useEffect, useState } from "react";
import { api, type Category, type PortfolioView } from "../../lib/api";
import { usePageTitle } from "../../lib/pageTitle";
export function AdminViews() {
const [views, setViews] = useState<PortfolioView[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [showOthers, setShowOthers] = useState(true);
const [isActive, setIsActive] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
usePageTitle(editingId ? "Edit view · Admin" : "Portfolio views · Admin");
function load() {
api.adminViews().then(setViews);
@@ -19,27 +27,34 @@ export function AdminViews() {
load();
}, []);
async function create(e: React.FormEvent) {
e.preventDefault();
await api.createView({
name,
slug: slug || undefined,
description,
showOthers: true,
categories: selected.map((categoryId, priority) => ({ categoryId, priority })),
});
function resetForm() {
setEditingId(null);
setName("");
setSlug("");
setDescription("");
setSelected([]);
setMsg("View created");
load();
setShowOthers(true);
setIsActive(true);
}
async function remove(id: string, n: string) {
if (!confirm(`Delete view “${n}”?`)) return;
await api.deleteView(id);
load();
function startEdit(v: PortfolioView) {
setErr(null);
setMsg(null);
setEditingId(v.id);
setName(v.name);
setSlug(v.slug);
setDescription(v.description || "");
setShowOthers(v.showOthers !== false);
setIsActive(v.isActive !== false);
const ordered = [...(v.categoryPriorities || [])].sort(
(a, b) => (a.priority ?? 0) - (b.priority ?? 0)
);
setSelected(
ordered
.map((cp) => cp.categoryId || cp.category?.id)
.filter((id): id is string => !!id)
);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function toggleCat(id: string) {
@@ -60,34 +75,97 @@ export function AdminViews() {
});
}
async function save(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setErr(null);
setMsg(null);
const body = {
name: name.trim(),
slug: slug.trim() || undefined,
description: description.trim() || null,
showOthers,
isActive,
categories: selected.map((categoryId, priority) => ({ categoryId, priority })),
};
try {
if (editingId) {
await api.updateView(editingId, body);
setMsg(`Updated “${body.name}`);
} else {
await api.createView(body);
setMsg(`Created “${body.name}`);
}
resetForm();
load();
} catch (e) {
setErr((e as Error).message);
} finally {
setSaving(false);
}
}
async function remove(id: string, n: string) {
if (!confirm(`Delete view “${n}”? This cannot be undone.`)) return;
setErr(null);
try {
await api.deleteView(id);
if (editingId === id) resetForm();
setMsg(`Deleted “${n}`);
load();
} catch (e) {
setErr((e as Error).message);
}
}
return (
<div className="admin-page">
<h1>Portfolio views</h1>
<p className="admin-page__sub">
Human-readable URLs like <code>/portfolio/game-dev</code> that prioritize categories for
different audiences.
different audiences. Category chips (e.g. <code>/portfolio/photography</code>) still
hard-filter by category name views are for multi-category audience ordering.
</p>
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
{err && <div className="admin-msg admin-msg--err">{err}</div>}
<form className="admin-form" onSubmit={create}>
<form className="admin-form" onSubmit={save}>
<h2 style={{ fontSize: "1rem", margin: "0 0 1rem" }}>
{editingId ? "Edit view" : "Create view"}
</h2>
<div className="field">
<label>Name</label>
<input required value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="view-name">Name</label>
<input
id="view-name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="field">
<label>Slug (URL path)</label>
<label htmlFor="view-slug">Slug (URL path)</label>
<input
id="view-slug"
placeholder="game-dev"
value={slug}
onChange={(e) => setSlug(e.target.value)}
/>
{slug && (
<span style={{ fontSize: "0.8rem", color: "var(--text-dim)" }}>
Live URL: /portfolio/{slug}
</span>
)}
</div>
<div className="field">
<label>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
<label htmlFor="view-desc">Description</label>
<textarea
id="view-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="field">
<label>Category priority order (select, then reorder)</label>
<label>Category priority order (select, then reorder with )</label>
<div className="admin-check-grid">
{categories.map((c) => (
<label key={c.id}>
@@ -101,16 +179,29 @@ export function AdminViews() {
))}
</div>
{selected.length > 0 && (
<ol style={{ color: "var(--text-muted)" }}>
{selected.map((id) => {
<ol style={{ color: "var(--text-muted)", marginTop: "0.75rem" }}>
{selected.map((id, idx) => {
const c = categories.find((x) => x.id === id);
return (
<li key={id} style={{ marginBottom: 6 }}>
<strong style={{ color: "var(--accent)", marginRight: 6 }}>
{idx + 1}.
</strong>
{c?.name}{" "}
<button type="button" className="btn btn--ghost btn--sm" onClick={() => move(id, -1)}>
<button
type="button"
className="btn btn--ghost btn--sm"
onClick={() => move(id, -1)}
aria-label="Move up"
>
</button>
<button type="button" className="btn btn--ghost btn--sm" onClick={() => move(id, 1)}>
<button
type="button"
className="btn btn--ghost btn--sm"
onClick={() => move(id, 1)}
aria-label="Move down"
>
</button>
</li>
@@ -119,9 +210,34 @@ export function AdminViews() {
</ol>
)}
</div>
<button type="submit" className="btn btn--primary">
Create view
<div className="admin-check-grid" style={{ marginBottom: "1rem" }}>
<label>
<input
type="checkbox"
checked={showOthers}
onChange={(e) => setShowOthers(e.target.checked)}
/>
Show other categories after prioritized ones
</label>
<label>
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
Active (visible on site)
</label>
</div>
<div className="admin-page__toolbar">
<button type="submit" className="btn btn--primary" disabled={saving}>
{saving ? "Saving…" : editingId ? "Save changes" : "Create view"}
</button>
{editingId && (
<button type="button" className="btn btn--ghost" onClick={resetForm}>
Cancel edit
</button>
)}
</div>
</form>
<h2 style={{ fontSize: "1rem", marginTop: "2.5rem" }}>Existing views</h2>
@@ -131,12 +247,20 @@ export function AdminViews() {
<th>Name</th>
<th>URL</th>
<th>Category order</th>
<th>Flags</th>
<th></th>
</tr>
</thead>
<tbody>
{views.map((v) => (
<tr key={v.id}>
<tr
key={v.id}
style={
editingId === v.id
? { background: "var(--accent-soft)" }
: undefined
}
>
<td>{v.name}</td>
<td>
<a href={`/portfolio/${v.slug}`} target="_blank" rel="noreferrer">
@@ -149,7 +273,18 @@ export function AdminViews() {
.filter(Boolean)
.join(" → ") || "—"}
</td>
<td>
<td style={{ fontSize: "0.8rem", color: "var(--text-dim)" }}>
{v.isActive === false ? "inactive" : "active"}
{v.showOthers === false ? " · exclusive" : " · +others"}
</td>
<td style={{ whiteSpace: "nowrap" }}>
<button
type="button"
className="btn btn--ghost btn--sm"
onClick={() => startEdit(v)}
>
Edit
</button>
<button
type="button"
className="btn btn--danger btn--sm"