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
418
src/components/admin/fields.tsx
Normal file
418
src/components/admin/fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue