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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user