From 09a10061b2fbd69d8b09b2b67afb9be14bcb3272 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 16:10:39 +0200 Subject: [PATCH] feat(admin): ResourceEditPanel + field renderers (no autosave) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-slide panel that renders a resource's edit form. Driven by the URL: ?new=1 opens a fresh form, ?edit= hydrates with the current item. POSTs back to the same URL with _action (save | delete | ); the route handler in step 7 dispatches. - FieldRenderer.astro: dispatches on field.kind, wraps each field with label + helper text + error state. - fields/*.astro: one component per kind — Text, Textarea, Markdown (with Write/Preview toggle), Select, SelectAsync, MultiSelectAsync, MultiText (with add/remove), Date, Datetime, Number, Readonly. - ResourceEditPanel.astro: header (title + close X), scrollable body, sticky footer (save + per-resource secondary actions + destructive delete when ops.delete is defined and item exists). Scrim closes on click, Esc, or the close link. Confirm-before-submit honours action.confirmText. Embedded sub-form sections render a placeholder until step 8 wires the pulse renderer. - admin.css: panel chrome + scrim + slide-in keyframes, full field styling for every kind, mobile full-screen modal collapse. - preview.astro: exercises every field kind so the panel can be eyeballed in a logged-in session. Try /admin/preview?new=1 and /admin/preview?edit=. Autosave deferred to Phase 2 per the approved deltas. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/admin/admin.css | 416 ++++++++++++++++++ src/admin/components/FieldRenderer.astro | 57 +++ src/admin/components/ResourceEditPanel.astro | 238 ++++++++++ src/admin/components/fields/DateField.astro | 29 ++ .../components/fields/DatetimeField.astro | 30 ++ .../components/fields/MarkdownField.astro | 41 ++ .../fields/MultiSelectAsyncField.astro | 47 ++ .../components/fields/MultiTextField.astro | 53 +++ src/admin/components/fields/NumberField.astro | 24 + .../components/fields/ReadonlyField.astro | 14 + .../components/fields/SelectAsyncField.astro | 25 ++ src/admin/components/fields/SelectField.astro | 24 + src/admin/components/fields/TextField.astro | 23 + .../components/fields/TextareaField.astro | 22 + src/pages/admin/preview.astro | 104 ++++- 15 files changed, 1139 insertions(+), 8 deletions(-) create mode 100644 src/admin/components/FieldRenderer.astro create mode 100644 src/admin/components/ResourceEditPanel.astro create mode 100644 src/admin/components/fields/DateField.astro create mode 100644 src/admin/components/fields/DatetimeField.astro create mode 100644 src/admin/components/fields/MarkdownField.astro create mode 100644 src/admin/components/fields/MultiSelectAsyncField.astro create mode 100644 src/admin/components/fields/MultiTextField.astro create mode 100644 src/admin/components/fields/NumberField.astro create mode 100644 src/admin/components/fields/ReadonlyField.astro create mode 100644 src/admin/components/fields/SelectAsyncField.astro create mode 100644 src/admin/components/fields/SelectField.astro create mode 100644 src/admin/components/fields/TextField.astro create mode 100644 src/admin/components/fields/TextareaField.astro diff --git a/src/admin/admin.css b/src/admin/admin.css index 08bbaff..2afeb88 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -883,3 +883,419 @@ } .bs-grid-td { font-size: 13px; } } + +/* =========================================================================== + * ResourceEditPanel — step 6 + * + * Right-slide panel + form. Scrim overlays the page; clicking outside the + * panel closes it (the scrim is itself a link to the close URL). All form + * state lives in the URL / form data — no in-memory state. + * ========================================================================= */ + +.bs-panel-scrim { + position: fixed; + inset: 0; + background: rgba(42, 37, 32, 0.30); + z-index: 90; + display: flex; + justify-content: flex-end; + animation: bs-fade-in var(--duration-fast) var(--ease-standard); +} + +@keyframes bs-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.bs-panel-scrim-link { + position: absolute; + inset: 0; + border-bottom: none; + cursor: default; /* The X button is the real close affordance. */ +} + +.bs-panel { + position: relative; + width: min(440px, 100vw); + height: 100vh; + background: var(--admin-panel-bg); + box-shadow: var(--admin-panel-shadow); + display: flex; + flex-direction: column; + animation: bs-slide-in 180ms var(--ease-standard); +} + +@keyframes bs-slide-in { + from { transform: translateX(20px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* ── Panel header ───────────────────────────────────────────────── */ +.bs-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6) var(--space-4); + border-bottom: 1px solid var(--admin-sidebar-border); +} +.bs-panel-title { + font-family: var(--font-serif); + font-weight: 400; + font-size: 20px; + letter-spacing: var(--tracking-snug); + color: var(--on-surface); + margin: 0; +} +.bs-panel-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-family: var(--font-sans); + font-size: 22px; + line-height: 1; + color: var(--on-surface-muted); + text-decoration: none; + border-bottom: none; + transition: background var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} +.bs-panel-close:hover { + background: var(--admin-row-hover); + color: var(--on-surface); + border-bottom: none; +} + +/* ── Panel form ─────────────────────────────────────────────────── */ +.bs-panel-form { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.bs-panel-body { + flex: 1; + overflow-y: auto; + padding: var(--space-5) var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +/* ── Field + label + helper + error ─────────────────────────────── */ +.bs-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.bs-label { + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--on-surface-variant); + font-weight: 500; +} +.bs-required { + color: var(--pigment-terracotta); + margin-left: 4px; +} +.bs-helper { + font-size: 11px; + line-height: 1.5; + color: var(--on-surface-muted); + margin: 0; + font-style: italic; +} +.bs-field-error { + font-family: var(--font-sans); + font-size: 11px; + color: var(--pigment-terracotta); + margin: 0; + letter-spacing: var(--tracking-wide); +} + +/* ── Inputs ─────────────────────────────────────────────────────── */ +.bs-input { + width: 100%; + padding: 9px 12px; + background: var(--surface-container-lowest); + border: 1px solid var(--admin-row-border); + border-radius: var(--radius-sm); + font-family: var(--font-sans); + font-size: 13px; + color: var(--on-surface); + outline: none; + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); + box-sizing: border-box; +} +.bs-input:focus { + border-color: var(--secondary); + box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.10); +} +.bs-input[readonly] { + background: var(--admin-row-hover); + color: var(--on-surface-variant); +} +.bs-textarea { + resize: vertical; + line-height: 1.6; + font-family: var(--font-sans); +} +.bs-select { cursor: pointer; } +.bs-readonly { + padding: 9px 12px; + background: var(--admin-row-hover); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--on-surface-variant); +} + +/* ── Markdown field ─────────────────────────────────────────────── */ +.bs-md { display: flex; flex-direction: column; gap: 6px; } +.bs-md-toolbar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--admin-row-border); +} +.bs-md-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 6px 12px; + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--on-surface-muted); + cursor: pointer; + transition: color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} +.bs-md-tab:hover { color: var(--on-surface-variant); } +.bs-md-tab.is-active { + color: var(--on-surface); + border-bottom-color: var(--admin-active-accent); +} +.bs-md-input { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; +} +.bs-md-preview { + padding: var(--space-3) var(--space-4); + background: var(--admin-row-hover); + border-radius: var(--radius-sm); + font-size: 13px; + line-height: 1.6; + color: var(--on-surface); +} +.bs-md-preview > :first-child { margin-top: 0; } +.bs-md-preview > :last-child { margin-bottom: 0; } +.bs-md-empty { + color: var(--on-surface-muted); + font-style: italic; + margin: 0; +} + +/* ── MultiText field ────────────────────────────────────────────── */ +.bs-multitext { display: flex; flex-direction: column; gap: var(--space-2); } +.bs-multitext-rows { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.bs-multitext-row { + display: flex; + gap: var(--space-2); + align-items: center; +} +.bs-multitext-row .bs-input { flex: 1; } +.bs-multitext-remove { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--admin-row-border); + background: transparent; + color: var(--on-surface-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} +.bs-multitext-remove:hover { + background: rgba(185, 107, 88, 0.08); + color: var(--pigment-terracotta); +} +.bs-multitext-remove:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.bs-multitext-add { + align-self: flex-start; + background: none; + border: none; + padding: 4px 0; + font-family: var(--font-sans); + font-size: 11px; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--on-surface-muted); + cursor: pointer; + transition: color var(--duration-fast) var(--ease-standard); +} +.bs-multitext-add:hover { color: var(--on-surface); } +.bs-multitext-add:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ── MultiSelect field ──────────────────────────────────────────── */ +.bs-multiselect { + border: 1px solid var(--admin-row-border); + border-radius: var(--radius-sm); + padding: var(--space-2); + display: flex; + flex-direction: column; + gap: 4px; + max-height: 220px; + overflow-y: auto; + background: var(--surface-container-lowest); +} +.bs-multiselect-row { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 6px; + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--on-surface); + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard); +} +.bs-multiselect-row:hover { background: var(--admin-row-hover); } +.bs-multiselect-empty { + color: var(--on-surface-muted); + font-style: italic; + font-size: 12px; + margin: 0; + padding: 6px; +} +.bs-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ── Embedded sub-form section (pulse inside dispatch) ──────────── */ +.bs-embed { + padding: var(--space-4); + background: var(--admin-row-hover); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.bs-embed-title { + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--on-surface-muted); + margin: 0; + font-weight: 500; +} +.bs-embed-placeholder { + font-size: 12px; + color: var(--on-surface-muted); + margin: 0; +} + +/* ── Panel footer ───────────────────────────────────────────────── */ +.bs-panel-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--admin-sidebar-border); + background: var(--admin-panel-bg); +} +.bs-panel-foot-left { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} +.bs-panel-save { + padding: 9px 18px; + background: var(--ink); + color: var(--on-ink); + border: none; + border-radius: 999px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + cursor: pointer; + transition: opacity var(--duration-fast) var(--ease-standard); +} +.bs-panel-save:hover { opacity: 0.88; } + +.bs-panel-secondary { + padding: 9px 14px; + background: transparent; + color: var(--on-surface); + border: 1px solid var(--admin-row-border); + border-radius: 999px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} +.bs-panel-secondary:hover { + background: var(--admin-row-hover); +} +.bs-panel-secondary.destructive { + color: var(--pigment-terracotta); + border-color: rgba(185, 107, 88, 0.30); +} +.bs-panel-secondary.destructive:hover { + background: rgba(185, 107, 88, 0.08); +} + +.bs-panel-delete { + background: transparent; + border: none; + color: var(--pigment-terracotta); + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + cursor: pointer; + padding: 9px 12px; + border-radius: var(--radius-sm); + transition: background var(--duration-fast) var(--ease-standard); +} +.bs-panel-delete:hover { background: rgba(185, 107, 88, 0.08); } + +/* ── Mobile: full-screen modal ──────────────────────────────────── */ +@media (max-width: 767px) { + .bs-panel { width: 100vw; max-width: 100vw; } +} diff --git a/src/admin/components/FieldRenderer.astro b/src/admin/components/FieldRenderer.astro new file mode 100644 index 0000000..913fe90 --- /dev/null +++ b/src/admin/components/FieldRenderer.astro @@ -0,0 +1,57 @@ +--- +/* --------------------------------------------------------------------------- + * FieldRenderer — dispatches on field.kind to the right input component + * and wraps it with label + helper + error. + * + * Branches must stay exhaustive; the `never` fallback flags any unhandled + * Field kind at compile time. + * ------------------------------------------------------------------------- */ + +import TextField from './fields/TextField.astro'; +import TextareaField from './fields/TextareaField.astro'; +import MarkdownField from './fields/MarkdownField.astro'; +import SelectField from './fields/SelectField.astro'; +import SelectAsyncField from './fields/SelectAsyncField.astro'; +import MultiSelectAsyncField from './fields/MultiSelectAsyncField.astro'; +import MultiTextField from './fields/MultiTextField.astro'; +import DateField from './fields/DateField.astro'; +import DatetimeField from './fields/DatetimeField.astro'; +import NumberField from './fields/NumberField.astro'; +import ReadonlyField from './fields/ReadonlyField.astro'; +import type { Field } from '../resource-types'; + +interface Props { + field: Field; + value: unknown; + error?: string; + item: Record | null; +} + +const { field, value, error, item } = Astro.props; +--- + +
+ + + {field.kind === 'text' && } + {field.kind === 'textarea' && } + {field.kind === 'markdown' && } + {field.kind === 'select' && } + {field.kind === 'select-async' && } + {field.kind === 'multi-select-async' && } + {field.kind === 'multi-text' && } + {field.kind === 'date' && } + {field.kind === 'datetime' && } + {field.kind === 'number' && } + {field.kind === 'readonly' && } + + {field.helperText && ( +

{field.helperText}

+ )} + {error && ( + + )} +
diff --git a/src/admin/components/ResourceEditPanel.astro b/src/admin/components/ResourceEditPanel.astro new file mode 100644 index 0000000..79716cf --- /dev/null +++ b/src/admin/components/ResourceEditPanel.astro @@ -0,0 +1,238 @@ +--- +/* --------------------------------------------------------------------------- + * ResourceEditPanel — right-slide panel for create + edit. + * + * Rendered alongside ResourceListView when the URL carries ?edit= or + * ?new=1. POSTs back to the same URL; the route handler in step 7 reads + * _action (save | delete | ) and dispatches. + * + * Visible-when predicates are evaluated server-side against current values + * (existing item or form defaults). Live-toggle while editing is Phase 2. + * ------------------------------------------------------------------------- */ + +import FieldRenderer from './FieldRenderer.astro'; +import type { Field, FieldContext, Resource } from '../resource-types'; + +interface Props { + resource: Resource; + /** The item being edited, or null when creating. */ + item: Record | null; + /** Pre-validated form values from a failed prior submission (re-fill). */ + formValues?: Record; + errors?: Record; + actingUserId: number; +} + +const { resource, item, formValues, errors = {}, actingUserId } = Astro.props; + +if (!resource.form) { + throw new Error(`ResourceEditPanel: ${resource.key} has form: null`); +} + +const isCreate = item === null; +const singular = resource.singularLabel.toLowerCase(); +const title = isCreate ? `New ${singular}` : `Edit ${singular}`; + +// Initial form values: prior failed submission > existing item > defaults +const seedValues: Record = { ...(item ?? {}), ...(formValues ?? {}) }; + +const ctx: FieldContext = { + formValues: seedValues, + item, + actingUserId, +}; + +function resolveDefault(field: Field): unknown { + if (field.defaultValue === undefined) return undefined; + if (typeof field.defaultValue === 'function') { + return (field.defaultValue as (c: FieldContext) => unknown)(ctx); + } + return field.defaultValue; +} + +function valueFor(field: Field): unknown { + if (field.key in seedValues) return seedValues[field.key]; + return resolveDefault(field); +} + +const visibleFields = resource.form.fields.filter( + (f) => !f.visibleWhen || f.visibleWhen(ctx), +); + +const embeds = resource.form.embeds ?? []; + +// Build the close URL — drop edit/new but keep filter/q/page +const closeUrl = (() => { + const next = new URLSearchParams(Astro.url.searchParams); + next.delete('edit'); + next.delete('new'); + const s = next.toString(); + return s ? `${Astro.url.pathname}?${s}` : Astro.url.pathname; +})(); + +// Actions visible for this item — only when editing an existing item +const actions = isCreate + ? [] + : (resource.actions ?? []).filter( + (a) => !a.visibleWhen || a.visibleWhen(item!), + ); + +// Form action URL: keep the panel-state params so a re-render after a +// validation failure stays on the same item. +const formAction = Astro.url.pathname + Astro.url.search; +--- + +
+ + + +
+ + diff --git a/src/admin/components/fields/DateField.astro b/src/admin/components/fields/DateField.astro new file mode 100644 index 0000000..51faaf3 --- /dev/null +++ b/src/admin/components/fields/DateField.astro @@ -0,0 +1,29 @@ +--- +import type { DateField } from '../../resource-types'; + +interface Props { + field: DateField; + value: unknown; +} + +const { field, value } = Astro.props; + +// Coerce ISO datetime → "YYYY-MM-DD" for the control. +function toDateInputValue(v: unknown): string { + if (v == null || v === '') return ''; + const s = String(v); + const m = s.match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : ''; +} +const v = toDateInputValue(value); +--- + + diff --git a/src/admin/components/fields/DatetimeField.astro b/src/admin/components/fields/DatetimeField.astro new file mode 100644 index 0000000..d17cb4e --- /dev/null +++ b/src/admin/components/fields/DatetimeField.astro @@ -0,0 +1,30 @@ +--- +import type { DatetimeField } from '../../resource-types'; + +interface Props { + field: DatetimeField; + value: unknown; +} + +const { field, value } = Astro.props; + +// Coerce ISO datetime (which may be "YYYY-MM-DD HH:MM:SS" SQLite-style or +// "YYYY-MM-DDTHH:mm:ss[Z|+offset]") to the "YYYY-MM-DDTHH:mm" the input wants. +function toDatetimeLocal(v: unknown): string { + if (v == null || v === '') return ''; + const s = String(v).replace(' ', 'T'); + const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/); + return m ? m[1] : ''; +} +const v = toDatetimeLocal(value); +--- + + diff --git a/src/admin/components/fields/MarkdownField.astro b/src/admin/components/fields/MarkdownField.astro new file mode 100644 index 0000000..6c749c4 --- /dev/null +++ b/src/admin/components/fields/MarkdownField.astro @@ -0,0 +1,41 @@ +--- +/* --------------------------------------------------------------------------- + * MarkdownField — textarea with a Preview toggle. + * + * The preview panel is rendered server-side once with the current value, so + * it's available the moment the toggle flips even without a network call. + * Toggling on does NOT re-render — that's a Phase 2 enhancement. The toggle + * itself is keyboard-accessible. + * ------------------------------------------------------------------------- */ + +import { renderMd } from '../../../lib/markdown'; +import type { MarkdownField } from '../../resource-types'; + +interface Props { + field: MarkdownField; + value: unknown; +} + +const { field, value } = Astro.props; +const v = value == null ? '' : String(value); +const previewHtml = v ? renderMd(v) : '

Nothing to preview yet.

'; +--- + +
+
+ + +
+ + + + diff --git a/src/admin/components/fields/MultiSelectAsyncField.astro b/src/admin/components/fields/MultiSelectAsyncField.astro new file mode 100644 index 0000000..aaff9f8 --- /dev/null +++ b/src/admin/components/fields/MultiSelectAsyncField.astro @@ -0,0 +1,47 @@ +--- +/* --------------------------------------------------------------------------- + * MultiSelectAsyncField — checkbox grid for picking multiple options. + * + * Submits as repeated form values under field.key[] (browser default for + * multiple checkboxes with the same name). The route handler in step 7 + * normalises the array form via getAll(). + * ------------------------------------------------------------------------- */ + +import type { MultiSelectAsyncField } from '../../resource-types'; + +interface Props { + field: MultiSelectAsyncField; + value: unknown; +} + +const { field, value } = Astro.props; +const options = await field.loadOptions(); + +const selected = new Set(); +if (Array.isArray(value)) { + for (const v of value) selected.add(String(v)); +} +--- + +
+ {field.label} + {options.length === 0 && ( +

No options available.

+ )} + {options.map(opt => { + const id = `f-${field.key}-${opt.value}`; + const isChecked = selected.has(String(opt.value)); + return ( + + ); + })} +
diff --git a/src/admin/components/fields/MultiTextField.astro b/src/admin/components/fields/MultiTextField.astro new file mode 100644 index 0000000..d5a7ac9 --- /dev/null +++ b/src/admin/components/fields/MultiTextField.astro @@ -0,0 +1,53 @@ +--- +/* --------------------------------------------------------------------------- + * MultiTextField — N text inputs with add/remove, used for pulse options. + * + * Submits as repeated form values under field.key. Initial input count is + * Math.max(minItems, value.length). Add/remove buttons are managed by a + * small client script attached to the panel. + * ------------------------------------------------------------------------- */ + +import type { MultiTextField } from '../../resource-types'; + +interface Props { + field: MultiTextField; + value: unknown; +} + +const { field, value } = Astro.props; +const min = field.minItems ?? 1; +const max = field.maxItems ?? 10; + +const initialValues: string[] = Array.isArray(value) + ? value.map((v) => (v == null ? '' : String(v))) + : []; +const initialCount = Math.max(min, initialValues.length, 1); +while (initialValues.length < initialCount) initialValues.push(''); +--- + +
+
+ {initialValues.map((v, i) => ( +
+ + +
+ ))} +
+ +
diff --git a/src/admin/components/fields/NumberField.astro b/src/admin/components/fields/NumberField.astro new file mode 100644 index 0000000..6490941 --- /dev/null +++ b/src/admin/components/fields/NumberField.astro @@ -0,0 +1,24 @@ +--- +import type { NumberField } from '../../resource-types'; + +interface Props { + field: NumberField; + value: unknown; +} + +const { field, value } = Astro.props; +const v = value == null ? '' : String(value); +--- + + diff --git a/src/admin/components/fields/ReadonlyField.astro b/src/admin/components/fields/ReadonlyField.astro new file mode 100644 index 0000000..c5a3817 --- /dev/null +++ b/src/admin/components/fields/ReadonlyField.astro @@ -0,0 +1,14 @@ +--- +import type { ReadonlyField } from '../../resource-types'; + +interface Props { + field: ReadonlyField; + value: unknown; + item: Record | null; +} + +const { field, value, item } = Astro.props; +const display = field.render ? field.render(value, item) : (value == null ? '—' : String(value)); +--- + +
{display}
diff --git a/src/admin/components/fields/SelectAsyncField.astro b/src/admin/components/fields/SelectAsyncField.astro new file mode 100644 index 0000000..f18e589 --- /dev/null +++ b/src/admin/components/fields/SelectAsyncField.astro @@ -0,0 +1,25 @@ +--- +import type { SelectAsyncField } from '../../resource-types'; + +interface Props { + field: SelectAsyncField; + value: unknown; +} + +const { field, value } = Astro.props; +const current = value == null ? '' : String(value); +const options = await field.loadOptions(); +--- + + diff --git a/src/admin/components/fields/SelectField.astro b/src/admin/components/fields/SelectField.astro new file mode 100644 index 0000000..efd8479 --- /dev/null +++ b/src/admin/components/fields/SelectField.astro @@ -0,0 +1,24 @@ +--- +import type { SelectField } from '../../resource-types'; + +interface Props { + field: SelectField; + value: unknown; +} + +const { field, value } = Astro.props; +const current = value == null ? '' : String(value); +--- + + diff --git a/src/admin/components/fields/TextField.astro b/src/admin/components/fields/TextField.astro new file mode 100644 index 0000000..11221b5 --- /dev/null +++ b/src/admin/components/fields/TextField.astro @@ -0,0 +1,23 @@ +--- +import type { TextField } from '../../resource-types'; + +interface Props { + field: TextField; + value: unknown; +} + +const { field, value } = Astro.props; +const v = value == null ? '' : String(value); +--- + + diff --git a/src/admin/components/fields/TextareaField.astro b/src/admin/components/fields/TextareaField.astro new file mode 100644 index 0000000..f5f54fc --- /dev/null +++ b/src/admin/components/fields/TextareaField.astro @@ -0,0 +1,22 @@ +--- +import type { TextareaField } from '../../resource-types'; + +interface Props { + field: TextareaField; + value: unknown; +} + +const { field, value } = Astro.props; +const v = value == null ? '' : String(value); +--- + + diff --git a/src/pages/admin/preview.astro b/src/pages/admin/preview.astro index dc6638a..e70535f 100644 --- a/src/pages/admin/preview.astro +++ b/src/pages/admin/preview.astro @@ -2,14 +2,19 @@ /* --------------------------------------------------------------------------- * /admin/preview — temporary smoke route for the Backstage shell. * - * Defines a one-off dispatches resource inline so the list view can be - * visually verified before the real resources land in steps 8–10. - * Deleted in step 11 once the new admin replaces the old. + * Inline sample dispatches resource exercises the list view + edit panel + * (every field kind is represented). Deleted in step 11 when the new admin + * replaces the old. * ------------------------------------------------------------------------- */ import AdminLayout from '../../admin/components/AdminLayout.astro'; import ResourceListView from '../../admin/components/ResourceListView.astro'; -import { getAllDispatchesForAdmin } from '../../lib/db'; +import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro'; +import { + getAllDispatchesForAdmin, + getDispatchById, + type DispatchWithAuthor, +} from '../../lib/db'; import type { Resource, ResourceGroup } from '../../admin/resource-types'; const user = Astro.locals.user; @@ -18,8 +23,8 @@ if (user.role !== 'fenja') { return Astro.redirect('/'); } -// Sample dispatches resource — lives only in this preview route. Step 8 will -// move this to src/admin/resources/dispatches.ts as the production config. +// Inline preview resource — exercises every field kind so the panel can +// be eyeballed in isolation. Step 8 ships the production dispatches config. const dispatchesPreview: Resource = { key: 'dispatches', label: 'Dispatches', @@ -84,8 +89,74 @@ const dispatchesPreview: Resource = { defaultSort: { key: 'updated_at', direction: 'desc' }, pageSize: 10, }, - form: { fields: [] }, // placeholder — real form lands in step 8 - ops: {}, + form: { + fields: [ + { key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 }, + { + key: 'kind', + label: 'Kind', + kind: 'select', + options: [ + { value: 'decision', label: 'Decision' }, + { value: 'update', label: 'Update' }, + { value: 'note', label: 'Note' }, + { value: 'behind_the_scenes', label: 'Behind the scenes' }, + ], + defaultValue: 'note', + }, + { + key: 'excerpt', + label: 'Excerpt', + kind: 'textarea', + rows: 4, + helperText: + 'Two to four sentences. First sentence becomes the lead paragraph on the dispatch banner; the rest follows in muted text.', + }, + { + key: 'body', + label: 'Body (markdown)', + kind: 'markdown', + rows: 14, + required: true, + }, + { + key: 'status', + label: 'Status on save', + kind: 'select', + options: [ + { value: 'draft', label: 'Draft (hidden from members)' }, + { value: 'published', label: 'Published (visible immediately)' }, + { value: 'archived', label: 'Archived (hidden from members)' }, + ], + defaultValue: 'draft', + }, + ], + embeds: [ + { key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }, + ], + }, + ops: { + getById: (id: number) => + getDispatchById(id) as unknown as Record | null, + delete: () => undefined, // surfaces the Delete button in the panel + }, + actions: [ + { + key: 'publish', + label: 'Publish now', + visibleWhen: (item) => (item as DispatchWithAuthor).status === 'draft', + confirmText: 'Publish this dispatch to all members?', + handler: () => undefined, + }, + { + key: 'archive', + label: 'Archive', + visibleWhen: (item) => (item as DispatchWithAuthor).status === 'published', + destructive: true, + confirmText: 'Archive this dispatch? It will be hidden from members.', + handler: () => undefined, + }, + ], }; const previewGroups: ResourceGroup[] = [ @@ -93,8 +164,25 @@ const previewGroups: ResourceGroup[] = [ { key: 'council', label: 'The council', resources: [] }, { key: 'system', label: 'System', resources: [] }, ]; + +// Panel state from URL +const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; +const isNew = Astro.url.searchParams.get('new') === '1'; +const showPanel = isNew || editId !== null; + +const editingItem = + editId !== null + ? ((await dispatchesPreview.ops.getById?.(editId)) ?? null) + : null; --- + {showPanel && ( + + )}