feat(admin): ResourceEditPanel + field renderers (no autosave)

Right-slide panel that renders a resource's edit form. Driven by the
URL: ?new=1 opens a fresh form, ?edit=<id> hydrates with the current
item. POSTs back to the same URL with _action (save | delete | <action
key>); 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=<id>.

Autosave deferred to Phase 2 per the approved deltas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 16:10:39 +02:00
parent cc9332e6e2
commit 09a10061b2
15 changed files with 1139 additions and 8 deletions

View file

@ -883,3 +883,419 @@
} }
.bs-grid-td { font-size: 13px; } .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; }
}

View file

@ -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<string, unknown> | null;
}
const { field, value, error, item } = Astro.props;
---
<div class="bs-field" data-field={field.key}>
<label class="bs-label" for={`f-${field.key}`}>
{field.label}
{field.required && <span class="bs-required" aria-hidden="true">*</span>}
</label>
{field.kind === 'text' && <TextField field={field} value={value} />}
{field.kind === 'textarea' && <TextareaField field={field} value={value} />}
{field.kind === 'markdown' && <MarkdownField field={field} value={value} />}
{field.kind === 'select' && <SelectField field={field} value={value} />}
{field.kind === 'select-async' && <SelectAsyncField field={field} value={value} />}
{field.kind === 'multi-select-async' && <MultiSelectAsyncField field={field} value={value} />}
{field.kind === 'multi-text' && <MultiTextField field={field} value={value} />}
{field.kind === 'date' && <DateField field={field} value={value} />}
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
{field.kind === 'number' && <NumberField field={field} value={value} />}
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
{field.helperText && (
<p class="bs-helper">{field.helperText}</p>
)}
{error && (
<p class="bs-field-error" role="alert">{error}</p>
)}
</div>

View file

@ -0,0 +1,238 @@
---
/* ---------------------------------------------------------------------------
* ResourceEditPanel — right-slide panel for create + edit.
*
* Rendered alongside ResourceListView when the URL carries ?edit=<id> or
* ?new=1. POSTs back to the same URL; the route handler in step 7 reads
* _action (save | delete | <action.key>) 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<string, unknown> | null;
/** Pre-validated form values from a failed prior submission (re-fill). */
formValues?: Record<string, unknown>;
errors?: Record<string, string>;
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<string, unknown> = { ...(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;
---
<div class="bs-panel-scrim">
<a href={closeUrl} class="bs-panel-scrim-link" aria-label="Close panel"></a>
<aside class="bs-panel" role="dialog" aria-modal="true" aria-label={title}>
<header class="bs-panel-head">
<h2 class="bs-panel-title">{title}</h2>
<a href={closeUrl} class="bs-panel-close" aria-label="Close">×</a>
</header>
<form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form">
<div class="bs-panel-body">
{visibleFields.map((field) => (
<FieldRenderer
field={field}
value={valueFor(field)}
error={errors[field.key]}
item={item}
/>
))}
{embeds.length > 0 && embeds.map((embed) => {
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
return show && (
<section class="bs-embed" data-embed={embed.key}>
<h3 class="bs-embed-title">{embed.title}</h3>
{/*
* Embed components are resolved per-resource. The pulse-sub-form
* renderer is wired in when the dispatches resource lands in step 8.
*/}
<p class="bs-embed-placeholder">
<em>{embed.component} renderer will be wired in step 8.</em>
</p>
</section>
);
})}
</div>
<footer class="bs-panel-foot">
<div class="bs-panel-foot-left">
<button type="submit" name="_action" value="save" class="bs-panel-save">
{isCreate ? `Create ${singular}` : 'Save'}
</button>
{actions.map((a) => (
<button
type="submit"
name="_action"
value={a.key}
class:list={['bs-panel-secondary', { destructive: a.destructive }]}
data-confirm={a.confirmText ?? null}
>
{a.label}
</button>
))}
</div>
{!isCreate && resource.ops.delete && (
<button
type="submit"
name="_action"
value="delete"
class="bs-panel-delete"
data-confirm={`Delete this ${singular}? This cannot be undone.`}
>
Delete
</button>
)}
</footer>
</form>
</aside>
</div>
<script>
// ── Confirm-before-submit for buttons with data-confirm ──────────────────
document.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement | null)?.closest('button[data-confirm]');
if (!(btn instanceof HTMLButtonElement)) return;
const text = btn.getAttribute('data-confirm');
if (text && !window.confirm(text)) {
e.preventDefault();
e.stopPropagation();
}
});
// ── Escape to close ──────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const closer = document.querySelector('.bs-panel-close') as HTMLAnchorElement | null;
if (closer) closer.click();
});
// ── Markdown Write/Preview toggle ────────────────────────────────────────
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
if (!input || !preview) return;
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const mode = tab.getAttribute('data-md-mode');
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
if (mode === 'preview') {
input.hidden = true;
preview.hidden = false;
} else {
input.hidden = false;
preview.hidden = true;
}
});
});
});
// ── MultiTextField add / remove ──────────────────────────────────────────
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');
const addBtn = root.querySelector<HTMLButtonElement>('.bs-multitext-add');
if (!rows || !addBtn) return;
const fieldKey = root.dataset.multitext ?? 'option';
const min = Number(root.dataset.min ?? '1');
const max = Number(root.dataset.max ?? '10');
function updateButtons() {
const inputs = rows!.querySelectorAll<HTMLElement>('.bs-multitext-row');
addBtn!.disabled = inputs.length >= max;
inputs.forEach((row) => {
const rm = row.querySelector<HTMLButtonElement>('.bs-multitext-remove');
if (rm) rm.disabled = inputs.length <= min;
});
}
rows.addEventListener('click', (e) => {
const rm = (e.target as HTMLElement | null)?.closest('.bs-multitext-remove');
if (!rm) return;
const row = rm.closest('.bs-multitext-row');
if (row && rows.querySelectorAll('.bs-multitext-row').length > min) {
row.remove();
updateButtons();
}
});
addBtn.addEventListener('click', () => {
const inputs = rows.querySelectorAll('.bs-multitext-row');
if (inputs.length >= max) return;
const row = document.createElement('div');
row.className = 'bs-multitext-row';
row.innerHTML =
`<input type="text" name="${fieldKey}" class="bs-input" value="" placeholder="Option ${inputs.length + 1}" />` +
`<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>`;
rows.appendChild(row);
updateButtons();
});
updateButtons();
});
</script>

View file

@ -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 <input type="date"> 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);
---
<input
type="date"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -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);
---
<input
type="datetime-local"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -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) : '<p class="bs-md-empty">Nothing to preview yet.</p>';
---
<div class="bs-md" data-md-field={field.key}>
<div class="bs-md-toolbar">
<button type="button" class="bs-md-tab is-active" data-md-mode="edit">Write</button>
<button type="button" class="bs-md-tab" data-md-mode="preview">Preview</button>
</div>
<textarea
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-textarea bs-md-input"
rows={field.rows ?? 14}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
>{v}</textarea>
<div class="bs-md-preview" hidden set:html={previewHtml} />
</div>

View file

@ -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<string>();
if (Array.isArray(value)) {
for (const v of value) selected.add(String(v));
}
---
<fieldset class="bs-multiselect" disabled={field.readOnly}>
<legend class="bs-visually-hidden">{field.label}</legend>
{options.length === 0 && (
<p class="bs-multiselect-empty">No options available.</p>
)}
{options.map(opt => {
const id = `f-${field.key}-${opt.value}`;
const isChecked = selected.has(String(opt.value));
return (
<label class="bs-multiselect-row" for={id}>
<input
type="checkbox"
id={id}
name={field.key}
value={String(opt.value)}
checked={isChecked}
/>
<span>{opt.label}</span>
</label>
);
})}
</fieldset>

View file

@ -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('');
---
<div
class="bs-multitext"
data-multitext={field.key}
data-min={min}
data-max={max}
>
<div class="bs-multitext-rows">
{initialValues.map((v, i) => (
<div class="bs-multitext-row">
<input
type="text"
name={field.key}
class="bs-input"
value={v}
placeholder={field.placeholderEach ?? `Option ${i + 1}`}
/>
<button
type="button"
class="bs-multitext-remove"
aria-label="Remove option"
>×</button>
</div>
))}
</div>
<button type="button" class="bs-multitext-add">+ Add option</button>
</div>

View file

@ -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);
---
<input
type="number"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
min={field.min}
max={field.max}
step={field.step}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -0,0 +1,14 @@
---
import type { ReadonlyField } from '../../resource-types';
interface Props {
field: ReadonlyField;
value: unknown;
item: Record<string, unknown> | null;
}
const { field, value, item } = Astro.props;
const display = field.render ? field.render(value, item) : (value == null ? '—' : String(value));
---
<div class="bs-readonly" id={`f-${field.key}`}>{display}</div>

View file

@ -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();
---
<select
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-select"
required={field.required}
disabled={field.readOnly}
>
{!field.required && <option value="">—</option>}
{options.map(opt => (
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
))}
</select>

View file

@ -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);
---
<select
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-select"
required={field.required}
disabled={field.readOnly}
>
{!field.required && <option value="">—</option>}
{field.options.map(opt => (
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
))}
</select>

View file

@ -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);
---
<input
type="text"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
placeholder={field.placeholder ?? ''}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -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);
---
<textarea
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-textarea"
rows={field.rows ?? 4}
placeholder={field.placeholder ?? ''}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
>{v}</textarea>

View file

@ -2,14 +2,19 @@
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* /admin/preview — temporary smoke route for the Backstage shell. * /admin/preview — temporary smoke route for the Backstage shell.
* *
* Defines a one-off dispatches resource inline so the list view can be * Inline sample dispatches resource exercises the list view + edit panel
* visually verified before the real resources land in steps 810. * (every field kind is represented). Deleted in step 11 when the new admin
* Deleted in step 11 once the new admin replaces the old. * replaces the old.
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
import AdminLayout from '../../admin/components/AdminLayout.astro'; import AdminLayout from '../../admin/components/AdminLayout.astro';
import ResourceListView from '../../admin/components/ResourceListView.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'; import type { Resource, ResourceGroup } from '../../admin/resource-types';
const user = Astro.locals.user; const user = Astro.locals.user;
@ -18,8 +23,8 @@ if (user.role !== 'fenja') {
return Astro.redirect('/'); return Astro.redirect('/');
} }
// Sample dispatches resource — lives only in this preview route. Step 8 will // Inline preview resource — exercises every field kind so the panel can
// move this to src/admin/resources/dispatches.ts as the production config. // be eyeballed in isolation. Step 8 ships the production dispatches config.
const dispatchesPreview: Resource = { const dispatchesPreview: Resource = {
key: 'dispatches', key: 'dispatches',
label: 'Dispatches', label: 'Dispatches',
@ -84,8 +89,74 @@ const dispatchesPreview: Resource = {
defaultSort: { key: 'updated_at', direction: 'desc' }, defaultSort: { key: 'updated_at', direction: 'desc' },
pageSize: 10, pageSize: 10,
}, },
form: { fields: [] }, // placeholder — real form lands in step 8 form: {
ops: {}, 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<string, unknown> | 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[] = [ const previewGroups: ResourceGroup[] = [
@ -93,8 +164,25 @@ const previewGroups: ResourceGroup[] = [
{ key: 'council', label: 'The council', resources: [] }, { key: 'council', label: 'The council', resources: [] },
{ key: 'system', label: 'System', 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;
--- ---
<AdminLayout title="Backstage preview" groups={previewGroups} activeResourceKey="dispatches"> <AdminLayout title="Backstage preview" groups={previewGroups} activeResourceKey="dispatches">
<ResourceListView resource={dispatchesPreview} groups={previewGroups} /> <ResourceListView resource={dispatchesPreview} groups={previewGroups} />
{showPanel && (
<ResourceEditPanel
resource={dispatchesPreview}
item={editingItem}
actingUserId={user.id}
/>
)}
</AdminLayout> </AdminLayout>