/* ═══════════════════════════════════════════════════════════════ 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) { // An entity with no slug has no heading path either, and a missing // path should read as "no value" rather than throwing on .split. if (!path) return undefined; 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 (
{widget === "checkbox" ? ( ) : widget === "textarea" ? (