v1.4 - added admin page and auth

This commit is contained in:
Zaldimmar 2026-09-25 02:36:49 -05:00
parent 5efdafbb97
commit 1f0aa3078f
29 changed files with 5264 additions and 217 deletions

View file

@ -0,0 +1,422 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN — ENTITY EDIT
Create and edit for every entity in the manifest. The form holds
the whole nested object — parent row, side tables, child
collections — and PATCH sends the lot. The server replaces
children wholesale, so what you see here is exactly what will
exist afterwards.
Nothing is written until Save, which is what makes removing a
repeater row safe: leaving without saving undoes it. That only
holds if leaving is hard to do by accident, hence the dirty
tracking and the two guards below.
A field the form never sets is left out of the payload entirely,
and the server lets the column's own DEFAULT apply. So a blank
new-record form is deliberate, not lazy — writing "" into every
field is what used to turn a default into a constraint failure.
updated_at rides along untouched. If someone else saved while
this page was open the server answers 409 rather than letting
one of you quietly overwrite the other. Entities with no
updated_at column simply never get one, and the 409 path stays
dormant for them.
slugFrom may name one field or several. Most ids are unique
because the name is: two organizations aren't both called
Northwest. Team ids are the exception — teams.id is a global
primary key, so every org's 'Board' would collide — which is
why the array form exists and teams uses it.
═══════════════════════════════════════════════════════════════ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { get, post, patch, del, ApiError } from "../../lib/api.js";
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
import { useAdminDetail } from "../../lib/adminTitle.tsx";
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
/* A foreign key refusing to budge is the most common way a save or
delete fails here, and SQLite's own wording explains nothing to
whoever is filling in the form. */
function friendly(message, singular) {
if (/FOREIGN KEY constraint failed/i.test(message ?? "")) {
return `Something still points at this ${singular}. Reassign or remove those first.`;
}
return message;
}
export default function EntityEdit() {
const { entity: entityKey, id } = useParams();
const manifest = ADMIN_ENTITIES[entityKey];
const navigate = useNavigate();
const { user } = useAuth();
const isNew = id === "new";
const canWrite = user?.role === "admin";
// Hoisted above the loading guards: the title hook below is a
// hook, so it can't sit after an early return, and it needs the
// same paths the heading uses.
const slugPaths = Array.isArray(manifest?.slugFrom)
? manifest.slugFrom
: [manifest?.slugFrom].filter(Boolean);
// Everything before the last path is a qualifier: a fact about
// another field rather than something to type. It renders as
// fixed text inside the slug box, and the editable part is only
// what follows it.
const qualifierPaths = slugPaths.slice(0, -1);
// Empty until every qualifier is chosen, because half a prefix
// would be saved into an id that then never matches.
const prefixOf = (source) => {
if (qualifierPaths.length === 0) return "";
const parts = qualifierPaths.map((path) => getPath(source, path));
if (parts.some((part) => !part)) return "";
return `${slugify(parts.join(" "))}-`;
};
// What to show greyed out before then: org_id becomes "org-".
const prefixHint = qualifierPaths.length
? `${qualifierPaths.map((path) => path.replace(/_id$/, "")).join("-")}-`
: "";
const tailOf = (source) => {
const prefix = prefixOf(source);
const value = source?.id ?? "";
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
};
// The heading wants the specific half, not the qualifier: a team
// page reads "Board", not "northwest Board".
const headingPath = slugPaths[slugPaths.length - 1];
const [form, setForm] = useState(null);
const [options, setOptions] = useState({});
const [errors, setErrors] = useState({});
const [message, setMessage] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [slugTouched, setSlugTouched] = useState(false);
// The last state the server confirmed. Everything else compares
// against this to decide whether there's anything to lose.
const baseline = useRef(null);
const load = useCallback(async () => {
if (!manifest) return;
setLoading(true);
setErrors({});
try {
const opts = await get("/admin/options", { ttl: 60_000 });
setOptions(opts.options);
if (isNew) {
// Blank children arrays matter: an absent key means "don't
// touch", which is wrong for a row that doesn't exist yet.
// The parent's own fields stay absent on purpose so the
// server's column defaults apply to whatever isn't filled in.
const blank = { id: "" };
for (const child of manifest.children ?? []) blank[child.key] = [];
setForm(blank);
baseline.current = JSON.stringify(blank);
} else {
const data = await get(`/admin/${manifest.key}/${id}`, { ttl: 0 });
setForm(data.row);
baseline.current = JSON.stringify(data.row);
}
setMessage(null);
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
setMessage({
tone: "error",
text: err instanceof ApiError ? err.message : "Couldn't load that.",
});
} finally {
setLoading(false);
}
}, [manifest, id, isNew, navigate]);
useEffect(() => {
load();
}, [load]);
const dirty = useMemo(
() => Boolean(form) && JSON.stringify(form) !== baseline.current,
[form],
);
// The tab says what's on screen; the layout adds the section and
// the site name. Null while loading, so it reads "Teams | NGU
// Admin CMS" for the half-second before the record arrives
// rather than flashing a slug.
useAdminDetail(
!manifest
? null
: isNew
? `New ${manifest.singular}`
: form
? getPath(form, headingPath) || form.id
: null,
);
// Closing the tab or hitting the browser back button skips React
// Router entirely, so the only hook available is this one.
useEffect(() => {
if (!dirty) return undefined;
const warn = (event) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
if (!manifest) return <p className="text-[#4a6b72]">No such thing to edit.</p>;
if (loading || !form) return <p className="text-[#4a6b72]">Loading…</p>;
/* ── Heading ───────────────────────────────────────────────── */
const heading = getPath(form, headingPath) || form.id;
const children = manifest.children ?? [];
/* ── Actions ───────────────────────────────────────────────── */
const leave = (to) => {
if (dirty && !window.confirm("Leave without saving? Your changes will be lost.")) return;
navigate(to);
};
const change = (path, value) => {
setForm((prev) => {
let next = setPath(prev, path, value);
// Recompose the id whenever one of its sources moves. The
// prefix always follows the organization — picking a
// different one has to change the slug, or it would claim a
// team belongs somewhere it doesn't. The tail only follows
// the name until someone types over it.
if (isNew && slugPaths.includes(path)) {
const tail = slugTouched
? tailOf(prev)
: slugify(getPath(next, headingPath) ?? "");
next = { ...next, id: `${prefixOf(next)}${tail}` };
}
return next;
});
// Clear this field's error as soon as it's touched; leaving a
// stale red outline on a field the user just fixed reads as a
// save that didn't take.
setErrors((prev) => (prev[path] ? { ...prev, [path]: undefined } : prev));
};
async function save() {
setSaving(true);
setErrors({});
setMessage(null);
try {
const data = isNew
? await post(`/admin/${manifest.key}`, form)
: await patch(`/admin/${manifest.key}/${id}`, form);
setForm(data.row);
baseline.current = JSON.stringify(data.row);
setMessage({ tone: "ok", text: "Saved." });
if (isNew) navigate(`/admin/${manifest.key}/${data.row.id}`, { replace: true });
} catch (err) {
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
if (err instanceof ApiError) {
// 409 means the updated_at we're holding is stale. Every
// further save will fail the same way until the page is
// reloaded, so offer that rather than just saying no.
if (err.status === 409) {
setMessage({ tone: "error", text: err.message, recover: "reload" });
} else {
setErrors(err.fields ?? {});
setMessage({
tone: "error",
text: err.fields
? "Some fields need attention."
: friendly(err.message, manifest.singular),
});
}
} else {
setMessage({ tone: "error", text: "Couldn't reach the server." });
}
} finally {
setSaving(false);
}
}
async function remove() {
if (!window.confirm(`Delete ${heading}? Its links, blocks and roles go with it.`)) return;
try {
await del(`/admin/${manifest.key}/${id}`);
baseline.current = JSON.stringify(form); // nothing left to warn about
navigate(`/admin/${manifest.key}`, { replace: true });
} catch (err) {
setMessage({
tone: "error",
text:
err instanceof ApiError
? friendly(err.message, manifest.singular)
: "Couldn't delete that.",
});
}
}
const visible = (when) => !when || getPath(form, when.path) === when.value;
return (
<div className="pb-24">
<button
type="button"
onClick={() => leave(`/admin/${manifest.key}`)}
className="text-sm text-[#4a6b72] hover:text-[#138ba0]"
>
← {manifest.label}
</button>
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
{isNew ? `New ${manifest.singular}` : heading}
</h1>
{/* Slug */}
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
<Field
field={{
path: "id",
label: manifest.idLabel,
prefix: isNew && qualifierPaths.length ? prefixOf(form) || prefixHint : undefined,
prefixPending: isNew && !prefixOf(form),
placeholder: isNew && qualifierPaths.length ? "board" : undefined,
readOnly: !isNew,
help: !isNew
? "Fixed once created — links and content blocks reference it."
: qualifierPaths.length
? "The prefix comes from the organization. Type the rest."
: "Lowercase, hyphens, no spaces. Can't be changed later.",
}}
value={isNew ? tailOf(form) : form.id}
error={errors.id}
onChange={(value) => {
setSlugTouched(true);
change("id", `${prefixOf(form)}${slugify(value)}`);
}}
/>
{!isNew && form.updated_at && (
<p className="mt-2 text-xs text-[#4a6b72]">
Last saved {form.updated_at}
</p>
)}
</div>
{/* Field groups */}
{manifest.groups.filter((group) => visible(group.when)).map((group) => (
<section
key={group.legend}
className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5"
>
<h2 className="text-lg font-semibold text-[#26454c]">{group.legend}</h2>
{group.note && <p className="mt-1 text-sm text-[#4a6b72]">{group.note}</p>}
<div className="mt-4">
<FieldGrid>
{group.fields.map((field) => (
<Field
key={field.path}
field={field}
value={getPath(form, field.path)}
options={options}
error={errors[field.path]}
onChange={(value) => change(field.path, value)}
/>
))}
</FieldGrid>
</div>
</section>
))}
{/* Child collections. Awards own none, so the panel would be
an empty white box — skip it rather than render it. */}
{children.some((child) => visible(child.when)) && (
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
{children
.filter((child) => visible(child.when))
.map((child) => (
<Repeater
key={child.key}
spec={child}
rows={form[child.key]}
options={options}
errors={errors}
errorPrefix={`${child.key}.`}
onChange={(rows) => setForm((prev) => ({ ...prev, [child.key]: rows }))}
/>
))}
</div>
)}
{/* Sticky action bar */}
<div className="fixed inset-x-0 bottom-0 border-t border-[#138ba0]/20 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-4 px-6 py-3">
{canWrite ? (
<>
<button
type="button"
onClick={save}
disabled={saving || !dirty}
className="rounded-full bg-[#138ba0] px-6 py-2 font-semibold text-white transition-colors hover:bg-[#0f7183] disabled:bg-[#4a6b72]/25"
>
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
</button>
{!isNew && (
<button
type="button"
onClick={remove}
className="rounded-full border border-[#b3261e]/40 px-4 py-2 text-sm font-medium text-[#b3261e] transition-colors hover:bg-[#fdf3f2]"
>
Delete
</button>
)}
</>
) : (
<span className="text-sm text-[#4a6b72]">
Read-only: your account can't save changes.
</span>
)}
{dirty && !saving && (
<span className="text-sm text-[#4a6b72]">Unsaved changes</span>
)}
{message && (
<span
role="status"
className={`flex items-center gap-2 text-sm ${
message.tone === "ok" ? "text-[#138ba0]" : "text-[#b3261e]"
}`}
>
{message.text}
{message.recover === "reload" && (
<button
type="button"
onClick={load}
className="rounded-full border border-[#b3261e]/40 px-3 py-1 text-xs font-medium"
>
Reload
</button>
)}
</span>
)}
</div>
</div>
</div>
);
}