{row.message}
+ ++ {row.name || row.email ? ( + <> + {row.name && {row.name}} + {row.name && row.email && " · "} + {row.email && ( + + {row.email} + + )} + > + ) : ( + Sent anonymously + )} +
+ + {canWrite && ( +{errorMessage}
; + if (!teamGroups) return{loadingMessage}
; + return{errorMessage}
; + if (!directory) return{loadingMessage}
; + return ( +{emptyMessage}
: null; } - const open = features.bio ? findByKey(resolvedGroups, openKey) : null; + const open = features.details ? findByKey(resolvedGroups, openKey) : null; - function toggle(group, person, index) { + function toggle(group: PeopleGroup, person: Person, index: number) { const key = keyFor(group, person, index); const next = openKey === key ? null : key; setOpenKey(next); - if (onExpand) onExpand(next ? person : null, next ? group : null); + onExpand?.(next ? person : null, next ? group : null); } - function handleKeyDown(event) { + function handleKeyDown(event: React.KeyboardEvent{person.name}
- {person.title &&{person.title}
} + {title &&{title}
}@@ -323,13 +692,13 @@ function Chevron() { ); } -/* helpers ------------------------------------------------------------- */ +/* ── Helpers ─────────────────────────────────────────────────── */ -function keyFor(group, person, index) { +function keyFor(group: PeopleGroup, person: Person, index: number): string { return `${group.id}:${person.id ?? person.name ?? index}`; } -function findByKey(groups, key) { +function findByKey(groups: PeopleGroup[], key: string | null) { if (!key) return null; for (const group of groups) { for (let index = 0; index < group.people.length; index += 1) { @@ -340,12 +709,36 @@ function findByKey(groups, key) { return null; } -function hasBio(person) { - if (Array.isArray(person.bio)) return person.bio.some(Boolean); - return Boolean(person.bio); +/* The seat they hold here, or what they're called when there is no + seat — the same fallback content.js applies to leadership rows. */ +function titleOf(person: Person): string | null { + return person.title || person.tagline || null; } -function initials(name = "") { +function owns(person: Person): boolean { + return Boolean(person.is_owner ?? person.isOwner); +} + +/* Anything the panel would have to show. Gating on bio alone left + every tile flat until someone wrote prose. */ +function hasDetails(person: Person): boolean { + if (Array.isArray(person.bio) ? person.bio.some(Boolean) : Boolean(person.bio)) { + return true; + } + return Boolean( + person.pronouns || resolveOrg(person.org) || person.location_label || person.public_email, + ); +} + +/* Database rows carry a bare filename; a hand-written entry may + give a path or a full URL. Both should work. */ +function photoSrc(photo?: string | null): string | null { + if (!photo) return null; + if (/^(https?:|\/|data:)/.test(photo)) return photo; + return `/people/${photo}`; +} + +function initials(name = ""): string { return name .trim() .split(/\s+/) @@ -355,21 +748,9 @@ function initials(name = "") { .toUpperCase(); } -function resolveAge(person) { - if (typeof person.age === "number") return person.age; - if (!person.birthdate) return null; - const born = new Date(person.birthdate); - if (Number.isNaN(born.getTime())) return null; - const now = new Date(); - let age = now.getFullYear() - born.getFullYear(); - const monthDelta = now.getMonth() - born.getMonth(); - if (monthDelta < 0 || (monthDelta === 0 && now.getDate() < born.getDate())) age -= 1; - return age >= 0 ? age : null; -} - -function resolveOrg(org) { +function resolveOrg(org: Person["org"]) { if (!org) return null; - if (typeof org === "string") return { name: org }; + if (typeof org === "string") return { name: org, href: undefined }; if (!org.name) return null; return org; } diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx new file mode 100644 index 0000000..ccf1be0 --- /dev/null +++ b/src/components/admin/fields.tsx @@ -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 ( +
{error}
+ ) : orphaned && !locked ? ( ++ This points at something that has been deleted. Pick a replacement before saving. +
+ ) : ( + field.help && + widget !== "checkbox" && ( +{field.help}
+ ) + )} +{spec.note}
} + + {list.length === 0 &&None yet.
} + +{row.message}
+ ++ {row.name || row.email ? ( + <> + {row.name && {row.name}} + {row.name && row.email && " · "} + {row.email && ( + + {row.email} + + )} + > + ) : ( + Sent anonymously + )} +
+ + {canWrite && ( ++ {canWrite + ? "Everything submitted through the site form." + : "Read-only: your account can't change statuses or notes."} +
+ + {/* Filters */} ++ {error} +
+ )} + + {!loading && rows.length === 0 && !error && ( ++ Nothing here. {status === "new" ? "The queue is clear." : "Try another filter."} +
+ )} + +Loading…
} + + {cursor && !loading && ( + + )} ++ Sign in to read and triage site feedback. +
+ + + ++ Accounts are created on the server. Ask whoever runs the box. +
+No such thing to edit.
; + if (loading || !form) returnLoading…
; + + /* ── 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 ( ++ Last saved {form.updated_at} +
+ )} +{group.note}
} +No such thing to edit.
; + } + + function setParam(key, value) { + const next = new URLSearchParams(params); + if (value) next.set(key, value); + else next.delete(key); + setParams(next, { replace: true }); + } + + function labelFor(filter, value) { + if (filter.optionsFrom) { + return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]); + } + return filter.options.map((o) => (Array.isArray(o) ? o : [o, o])); + } + + return ( ++ {error} +
+ )} + + {/* Rows */} +| + {column.label} + | + ))} +Slug | +
|---|---|
| + {column.widget === "bool" + ? row[column.key] + ? "Yes" + : "—" + : row[column.key] || "—"} + | + ))} +{row.id} | +
Nothing matches.
+ )} + {loading &&Loading…
} +