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:
parent
cc9332e6e2
commit
09a10061b2
15 changed files with 1139 additions and 8 deletions
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
57
src/admin/components/FieldRenderer.astro
Normal file
57
src/admin/components/FieldRenderer.astro
Normal 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>
|
||||||
238
src/admin/components/ResourceEditPanel.astro
Normal file
238
src/admin/components/ResourceEditPanel.astro
Normal 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>
|
||||||
29
src/admin/components/fields/DateField.astro
Normal file
29
src/admin/components/fields/DateField.astro
Normal 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}
|
||||||
|
/>
|
||||||
30
src/admin/components/fields/DatetimeField.astro
Normal file
30
src/admin/components/fields/DatetimeField.astro
Normal 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}
|
||||||
|
/>
|
||||||
41
src/admin/components/fields/MarkdownField.astro
Normal file
41
src/admin/components/fields/MarkdownField.astro
Normal 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>
|
||||||
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal file
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal 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>
|
||||||
53
src/admin/components/fields/MultiTextField.astro
Normal file
53
src/admin/components/fields/MultiTextField.astro
Normal 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>
|
||||||
24
src/admin/components/fields/NumberField.astro
Normal file
24
src/admin/components/fields/NumberField.astro
Normal 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}
|
||||||
|
/>
|
||||||
14
src/admin/components/fields/ReadonlyField.astro
Normal file
14
src/admin/components/fields/ReadonlyField.astro
Normal 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>
|
||||||
25
src/admin/components/fields/SelectAsyncField.astro
Normal file
25
src/admin/components/fields/SelectAsyncField.astro
Normal 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>
|
||||||
24
src/admin/components/fields/SelectField.astro
Normal file
24
src/admin/components/fields/SelectField.astro
Normal 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>
|
||||||
23
src/admin/components/fields/TextField.astro
Normal file
23
src/admin/components/fields/TextField.astro
Normal 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}
|
||||||
|
/>
|
||||||
22
src/admin/components/fields/TextareaField.astro
Normal file
22
src/admin/components/fields/TextareaField.astro
Normal 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>
|
||||||
|
|
@ -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 8–10.
|
* (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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue