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,418 @@
/* ═══════════════════════════════════════════════════════════════
ADMIN FORM PRIMITIVES
Field renders one input from a manifest entry. Repeater renders
an ordered collection of them, and nests one level for content
blocks and their items.
Ordering is array position — the server writes sort_order from
the index — so moving a row is a splice, not a number to
hand-edit. Rows can be dragged by the handle or moved with the
arrow buttons; the arrows are the keyboard path and stay whether
or not a pointer is in use.
A row added and never filled in is dropped by the server rather
than rejected, which depends on spec.blank seeding nothing the
server doesn't also declare as a column default. If you give a
blank row a starting value here, add the matching default: to
that column in the server's admin-schema.js or the row will be
saved as real input.
Two options change the box itself rather than what goes in it:
prefix fixed text inside the box, left of the cursor. The
value it decorates is only the part after it, so
the caller joins the two. Used by composed slugs,
where the prefix is a fact about another field
rather than something to retype.
readOnly shown, selectable, not editable. Deliberately not
`disabled`: a disabled control reads as switched
off and drops out of the tab order, whereas an
immutable id is settled fact you still want to be
able to read and copy.
═══════════════════════════════════════════════════════════════ */
import { useRef, useState } from "react";
const input =
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
/* Same box, but lit by the real input nested inside it. */
const inputShell =
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
"focus-within:ring-[#138ba0]/25";
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
const shellError =
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
/* Reads as settled fact rather than as an empty box someone forgot
to fill in. */
const inputLocked =
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
/* ── Dotted paths ────────────────────────────────────────────── */
export function getPath(object, path) {
return path.split(".").reduce((value, key) => value?.[key], object);
}
export function setPath(object, path, value) {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...object, [head]: value };
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
}
/* ── Field ───────────────────────────────────────────────────── */
export function Field({ field, value, row, options, error, onChange }) {
const id = `f-${field.path.replace(/\./g, "-")}`;
const widget = field.widget ?? "text";
const locked = Boolean(field.readOnly);
let list = null;
let orphaned = false;
if (widget === "select") {
list = field.optionsFrom
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
: (field.options ?? []).map((o) =>
Array.isArray(o) ? [o[0], o[1]] : [o, o],
);
if (field.filterBy && row) {
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
}
// A stored value with no matching option renders as the blank
// choice, which reads as "nobody set this" and saves as a
// deliberate clear. It usually means the row it pointed at was
// deleted, so keep it on screen and say so.
orphaned =
value != null &&
value !== "" &&
!list.some(([optionId]) => String(optionId) === String(value));
}
const common = {
id,
className: `${input} ${error ? inputError : ""}`,
value: value ?? "",
onChange: (e) => onChange(e.target.value),
};
return (
<div className={field.full ? "sm:col-span-2" : ""}>
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
{field.label}
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
</label>
<div className="mt-1.5">
{widget === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
<input
id={id}
type="checkbox"
checked={value === 1 || value === true}
disabled={locked}
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
/>
{field.help ?? "Yes"}
</label>
) : widget === "textarea" ? (
<textarea
{...common}
rows={4}
readOnly={locked}
className={`${locked ? inputLocked : common.className} resize-y`}
/>
) : widget === "select" ? (
// A select has no readOnly, so this one really does have
// to be disabled — there's no way to keep it focusable
// and still refuse a new choice.
<select
{...common}
disabled={locked}
className={`${locked ? inputLocked : common.className} ${
orphaned && !locked ? inputError : ""
}`}
>
<option value="">{field.blankLabel ?? "— choose —"}</option>
{orphaned && <option value={value}>{value} — no longer exists</option>}
{list.map(([id2, label]) => (
<option key={id2} value={id2}>
{label}
</option>
))}
</select>
) : widget === "color" ? (
<div className="flex gap-2">
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
disabled={locked}
onChange={(e) => onChange(e.target.value)}
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
/>
<input
{...common}
readOnly={locked}
placeholder="#138ba0"
className={locked ? inputLocked : common.className}
/>
</div>
) : field.prefix && !locked ? (
// The span is not a form control, so it can't be typed
// into, tabbed to, or selected by dragging through the
// field. Clicking it focuses the input, which is what
// makes the two read as one box.
<div
className={`${inputShell} ${error ? shellError : ""}`}
onClick={() => document.getElementById(id)?.focus()}
>
<span
aria-hidden="true"
className={`shrink-0 select-none ${
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
}`}
>
{field.prefix}
</span>
<input
id={id}
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
/>
</div>
) : (
<input
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
step={widget === "number" ? "any" : undefined}
{...common}
readOnly={locked}
aria-readonly={locked || undefined}
className={locked ? inputLocked : common.className}
/>
)}
</div>
{error ? (
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
) : orphaned && !locked ? (
<p className="mt-1 text-xs text-[#b3261e]">
This points at something that has been deleted. Pick a replacement before saving.
</p>
) : (
field.help &&
widget !== "checkbox" && (
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
)
)}
</div>
);
}
export function FieldGrid({ children }) {
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
}
/* ── Repeater ────────────────────────────────────────────────── */
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
const list = rows ?? [];
// Which row is in flight, and which one it's currently over.
// Both are per-Repeater, which is what keeps a drag inside a
// nested collection from being accepted by the outer one.
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const rowRefs = useRef([]);
const update = (index, next) =>
onChange(list.map((row, i) => (i === index ? next : row)));
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= list.length) return;
const next = [...list];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
const relocate = (from, to) => {
if (from === to || from == null || to == null) return;
const next = [...list];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onChange(next);
};
const endDrag = () => {
setDragIndex(null);
setOverIndex(null);
};
return (
<section className="mt-8">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
<button
type="button"
onClick={() => onChange([...list, { ...spec.blank }])}
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
>
{spec.addLabel ?? "Add"}
</button>
</div>
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
<div className="mt-3 space-y-3">
{list.map((row, index) => {
const dragging = dragIndex === index;
const over = overIndex === index && dragIndex !== null && !dragging;
return (
<div
key={index}
ref={(el) => {
rowRefs.current[index] = el;
}}
onDragOver={(e) => {
// A null dragIndex means the drag started in some
// other collection — leave it to whoever owns it.
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
if (overIndex !== index) setOverIndex(index);
}}
onDrop={(e) => {
if (dragIndex === null) return;
e.preventDefault();
e.stopPropagation();
relocate(dragIndex, index);
endDrag();
}}
className={
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
(over
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
: "border-[#4a6b72]/20 ") +
(dragging ? "opacity-50" : "")
}
>
<div className="mb-3 flex items-center gap-2">
<span
draggable
onDragStart={(e) => {
e.stopPropagation();
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
// Firefox won't start a drag without payload.
e.dataTransfer.setData("text/plain", String(index));
// Drag the whole row, not just the handle.
const el = rowRefs.current[index];
if (el) e.dataTransfer.setDragImage(el, 16, 16);
}}
onDragEnd={endDrag}
title="Drag to reorder"
aria-hidden="true"
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
>
⠿
</span>
<span className="text-sm font-medium text-[#26454c]">
{spec.title ? spec.title(row, options) : `#${index + 1}`}
</span>
<div className="ml-auto flex gap-1">
<IconButton
label="Move up"
onClick={() => move(index, -1)}
disabled={index === 0}
>
↑
</IconButton>
<IconButton
label="Move down"
onClick={() => move(index, 1)}
disabled={index === list.length - 1}
>
↓
</IconButton>
<IconButton
label="Remove"
danger
onClick={() => onChange(list.filter((_, i) => i !== index))}
>
✕
</IconButton>
</div>
</div>
<FieldGrid>
{spec.fields.map((field) => (
<Field
key={field.path}
field={field}
row={row}
value={row[field.path]}
options={options}
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
onChange={(value) => update(index, { ...row, [field.path]: value })}
/>
))}
</FieldGrid>
{(spec.children ?? []).map((nested) => (
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
<Repeater
spec={nested}
rows={row[nested.key]}
options={options}
errors={errors}
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
onChange={(value) => update(index, { ...row, [nested.key]: value })}
/>
</div>
))}
</div>
);
})}
</div>
</section>
);
}
function IconButton({ label, onClick, danger, disabled, children }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
(disabled
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
: danger
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
}
>
{children}
</button>
);
}