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; }
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 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.
|
||||
*
|
||||
* 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<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[] = [
|
||||
|
|
@ -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;
|
||||
---
|
||||
|
||||
<AdminLayout title="Backstage preview" groups={previewGroups} activeResourceKey="dispatches">
|
||||
<ResourceListView resource={dispatchesPreview} groups={previewGroups} />
|
||||
{showPanel && (
|
||||
<ResourceEditPanel
|
||||
resource={dispatchesPreview}
|
||||
item={editingItem}
|
||||
actingUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
</AdminLayout>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue