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 { useEffect, useState } from "react";
|
||||||
import { api, type Category, type PortfolioView } from "../../lib/api";
|
import { api, type Category, type PortfolioView } from "../../lib/api";
|
||||||
|
import { usePageTitle } from "../../lib/pageTitle";
|
||||||
|
|
||||||
export function AdminViews() {
|
export function AdminViews() {
|
||||||
const [views, setViews] = useState<PortfolioView[]>([]);
|
const [views, setViews] = useState<PortfolioView[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [showOthers, setShowOthers] = useState(true);
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
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() {
|
function load() {
|
||||||
api.adminViews().then(setViews);
|
api.adminViews().then(setViews);
|
||||||
@@ -19,27 +27,34 @@ export function AdminViews() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function create(e: React.FormEvent) {
|
function resetForm() {
|
||||||
e.preventDefault();
|
setEditingId(null);
|
||||||
await api.createView({
|
|
||||||
name,
|
|
||||||
slug: slug || undefined,
|
|
||||||
description,
|
|
||||||
showOthers: true,
|
|
||||||
categories: selected.map((categoryId, priority) => ({ categoryId, priority })),
|
|
||||||
});
|
|
||||||
setName("");
|
setName("");
|
||||||
setSlug("");
|
setSlug("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setMsg("View created");
|
setShowOthers(true);
|
||||||
load();
|
setIsActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: string, n: string) {
|
function startEdit(v: PortfolioView) {
|
||||||
if (!confirm(`Delete view “${n}”?`)) return;
|
setErr(null);
|
||||||
await api.deleteView(id);
|
setMsg(null);
|
||||||
load();
|
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) {
|
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 (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<h1>Portfolio views</h1>
|
<h1>Portfolio views</h1>
|
||||||
<p className="admin-page__sub">
|
<p className="admin-page__sub">
|
||||||
Human-readable URLs like <code>/portfolio/game-dev</code> that prioritize categories for
|
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>
|
</p>
|
||||||
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
|
{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">
|
<div className="field">
|
||||||
<label>Name</label>
|
<label htmlFor="view-name">Name</label>
|
||||||
<input required value={name} onChange={(e) => setName(e.target.value)} />
|
<input
|
||||||
|
id="view-name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Slug (URL path)</label>
|
<label htmlFor="view-slug">Slug (URL path)</label>
|
||||||
<input
|
<input
|
||||||
|
id="view-slug"
|
||||||
placeholder="game-dev"
|
placeholder="game-dev"
|
||||||
value={slug}
|
value={slug}
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{slug && (
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "var(--text-dim)" }}>
|
||||||
|
Live URL: /portfolio/{slug}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Description</label>
|
<label htmlFor="view-desc">Description</label>
|
||||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
|
<textarea
|
||||||
|
id="view-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<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">
|
<div className="admin-check-grid">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<label key={c.id}>
|
<label key={c.id}>
|
||||||
@@ -101,16 +179,29 @@ export function AdminViews() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
<ol style={{ color: "var(--text-muted)" }}>
|
<ol style={{ color: "var(--text-muted)", marginTop: "0.75rem" }}>
|
||||||
{selected.map((id) => {
|
{selected.map((id, idx) => {
|
||||||
const c = categories.find((x) => x.id === id);
|
const c = categories.find((x) => x.id === id);
|
||||||
return (
|
return (
|
||||||
<li key={id} style={{ marginBottom: 6 }}>
|
<li key={id} style={{ marginBottom: 6 }}>
|
||||||
|
<strong style={{ color: "var(--accent)", marginRight: 6 }}>
|
||||||
|
{idx + 1}.
|
||||||
|
</strong>
|
||||||
{c?.name}{" "}
|
{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>
|
||||||
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -119,9 +210,34 @@ export function AdminViews() {
|
|||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn--primary">
|
<div className="admin-check-grid" style={{ marginBottom: "1rem" }}>
|
||||||
Create view
|
<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>
|
</button>
|
||||||
|
{editingId && (
|
||||||
|
<button type="button" className="btn btn--ghost" onClick={resetForm}>
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2 style={{ fontSize: "1rem", marginTop: "2.5rem" }}>Existing views</h2>
|
<h2 style={{ fontSize: "1rem", marginTop: "2.5rem" }}>Existing views</h2>
|
||||||
@@ -131,12 +247,20 @@ export function AdminViews() {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
<th>Category order</th>
|
<th>Category order</th>
|
||||||
|
<th>Flags</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{views.map((v) => (
|
{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>{v.name}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href={`/portfolio/${v.slug}`} target="_blank" rel="noreferrer">
|
<a href={`/portfolio/${v.slug}`} target="_blank" rel="noreferrer">
|
||||||
@@ -149,7 +273,18 @@ export function AdminViews() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" → ") || "—"}
|
.join(" → ") || "—"}
|
||||||
</td>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--danger btn--sm"
|
className="btn btn--danger btn--sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user