v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
422
src/pages/admin/EntityEdit.tsx
Normal file
422
src/pages/admin/EntityEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue