feat(admin): ResourceListView + ListCell with filter-conditional columns

The shared list-rendering component every resource will use. Reads
URL state (?filter, ?q, ?page) and derives:

- active filter (with isDefault fallback)
- active column set (columnsByFilter[filterKey] override → columns)
- filtered + searched + sorted + paginated row set

Rows are full anchor elements pointing at ?edit=<id> so the table
is fully keyboard-navigable and works without JS. The "+ New" button
is suppressed when resource.form is null (activity, join_requests).

- ResourceListView.astro: page header (eyebrow + serif h1 + optional
  description + new-item button), toolbar (search form + filter
  chips), grid table with --bs-grid-cols set from column widths,
  pagination, mobile card collapse.
- ListCell.astro: discriminated render for text / pill / relative-date
  / number / tag-list columns.
- admin.css: list-view styles plus the full pill palette (decision,
  update, note, bts, published, draft, archived, open, closed,
  pending, accepted, expired, approved, declined, shipping, in-beta,
  exploring, considering, active, departed, pilot, cab, fenja).
- preview.astro: inline sample dispatches resource so the list view
  renders against real DB rows. Step 8 moves this to its production
  config; this inline copy disappears with the preview route in
  step 11.

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

View file

@ -539,3 +539,347 @@
padding: var(--space-6) var(--space-6); padding: var(--space-6) var(--space-6);
} }
} }
/* ===========================================================================
* ResourceListView step 5
*
* Page header, toolbar (search + filter chips), grid-based table with
* clickable rows (whole row is an anchor), pill cells, pagination.
* ========================================================================= */
.bs-list {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 1100px;
}
/* ── Page header ────────────────────────────────────────────────── */
.bs-list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-6);
}
.bs-list-heading {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 44rem;
}
.bs-list-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
.bs-list-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 36px;
line-height: 1.05;
color: var(--on-surface);
margin: 0;
letter-spacing: var(--tracking-tight);
}
.bs-list-desc {
font-size: 14px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0;
}
.bs-list-new {
flex-shrink: 0;
padding: 9px 18px;
background: var(--ink);
color: var(--on-ink);
border: none;
border-radius: 999px;
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.bs-list-new:hover { opacity: 0.88; border-bottom: none; color: var(--on-ink); }
/* ── Toolbar ────────────────────────────────────────────────────── */
.bs-toolbar {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.bs-search-form {
flex: 1;
min-width: 240px;
max-width: 360px;
}
.bs-search-input {
width: 100%;
padding: 9px 14px;
background: var(--surface-container-lowest);
border: 1px solid var(--admin-sidebar-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);
}
.bs-search-input:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.10);
}
.bs-search-input::placeholder { color: var(--on-surface-muted); }
.bs-filters {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.bs-chip {
display: inline-flex;
align-items: center;
padding: 5px 12px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
background: transparent;
border: 1px solid var(--admin-sidebar-border);
border-bottom: 1px solid var(--admin-sidebar-border);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard);
}
.bs-chip:hover {
color: var(--on-surface);
background: var(--admin-row-hover);
border-color: var(--admin-row-border);
border-bottom: 1px solid var(--admin-row-border);
}
.bs-chip.active {
color: var(--on-ink);
background: var(--ink);
border-color: var(--ink);
}
.bs-chip.active:hover {
color: var(--on-ink);
background: var(--ink);
}
/* ── Grid table ─────────────────────────────────────────────────── */
.bs-grid {
display: flex;
flex-direction: column;
--bs-grid-cols: 1fr; /* overridden inline per list */
}
.bs-grid-head,
.bs-grid-row {
display: grid;
grid-template-columns: var(--bs-grid-cols);
gap: var(--space-4);
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--admin-row-border);
}
.bs-grid-head {
border-bottom-color: var(--admin-sidebar-border);
}
.bs-grid-th {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
font-weight: 500;
}
.bs-grid-row {
color: var(--on-surface);
text-decoration: none;
border-bottom: 1px solid var(--admin-row-border);
transition: background var(--duration-fast) var(--ease-standard);
}
.bs-grid-row:hover {
background: var(--admin-row-hover);
border-bottom: 1px solid var(--admin-row-border);
color: var(--on-surface);
}
.bs-grid-td {
min-width: 0;
font-size: 13px;
color: var(--on-surface-variant);
}
/* ── Cell internals ─────────────────────────────────────────────── */
.bs-cell-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.bs-cell-title {
color: var(--on-surface);
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-cell-title.primary {
font-family: var(--font-serif);
font-weight: 400;
font-size: 15px;
letter-spacing: var(--tracking-snug);
}
.bs-cell-subtitle {
color: var(--on-surface-muted);
font-size: 11px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-cell-rel,
.bs-cell-number {
font-variant-numeric: tabular-nums;
color: var(--on-surface-variant);
font-size: 12px;
}
.bs-cell-empty {
color: var(--on-surface-muted);
font-size: 12px;
}
/* ── Pills ──────────────────────────────────────────────────────── */
.bs-pill {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-weight: 500;
}
.pill-decision { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
.pill-update { background: rgba(90, 109, 131, 0.12); color: #5a6d83; }
.pill-note { background: rgba(141, 122, 133, 0.12); color: #8d7a85; }
.pill-bts { background: rgba(120, 95, 83, 0.12); color: #785f53; }
.pill-published { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
.pill-draft { background: rgba(0, 0, 0, 0.06); color: var(--on-surface-variant); }
.pill-archived { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
.pill-open { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
.pill-closed { background: rgba(0, 0, 0, 0.06); color: var(--on-surface-variant); }
.pill-pending { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
.pill-accepted { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
.pill-expired { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
.pill-approved { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
.pill-declined { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
.pill-shipping { background: rgba(109, 140, 124, 0.18); color: #5a7268; }
.pill-in-beta { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
.pill-exploring { background: rgba(186, 186, 176, 0.20); color: var(--on-surface-variant); }
.pill-considering{ background: rgba(186, 186, 176, 0.10); color: var(--on-surface-muted); }
.pill-active { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
.pill-departed { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
.pill-pilot { background: rgba(120, 95, 83, 0.12); color: #785f53; }
.pill-cab { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
.pill-fenja { background: rgba(90, 109, 131, 0.12); color: #5a6d83; }
/* ── Tag list cell ──────────────────────────────────────────────── */
.bs-cell-tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bs-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: var(--admin-row-hover);
color: var(--on-surface-variant);
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wide);
}
/* ── Empty state ────────────────────────────────────────────────── */
.bs-list-empty {
padding: var(--space-8) 0;
text-align: center;
color: var(--on-surface-muted);
font-style: italic;
font-size: 13px;
margin: 0;
}
/* ── Pagination ─────────────────────────────────────────────────── */
.bs-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-6);
padding-top: var(--space-4);
}
.bs-page-link {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.bs-page-link:hover { color: var(--on-surface); border-bottom: none; }
.bs-page-link.disabled { color: var(--on-surface-muted); cursor: default; opacity: 0.5; }
.bs-page-status {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
font-variant-numeric: tabular-nums;
}
/* ── Mobile: collapse grid rows to stacked cards ────────────────── */
@media (max-width: 767px) {
.bs-list-header { flex-direction: column; align-items: flex-start; }
.bs-list-title { font-size: 28px; }
.bs-grid-head { display: none; }
.bs-grid-row {
grid-template-columns: 1fr;
gap: 6px;
padding: var(--space-4) var(--space-3);
border-bottom: 1px solid var(--admin-row-border);
}
.bs-grid-td { font-size: 13px; }
}

View file

@ -0,0 +1,112 @@
---
/* ---------------------------------------------------------------------------
* Internal cell renderer for ResourceListView.
*
* One Column kind per branch. Branches must stay exhaustive over Column<T>
* — if a new kind is added to resource-types.ts, TypeScript will warn here
* via the `never` fallback.
* ------------------------------------------------------------------------- */
import { relativeTime } from '../../lib/format';
import type { Column } from '../resource-types';
interface Props {
column: Column<Record<string, unknown>>;
item: Record<string, unknown>;
}
const { column, item } = Astro.props;
const kind = column.kind ?? 'text';
// Text column — default rendering ─────────────────────────────────────────
let textTitle: string | null = null;
let textSubtitle: string | null = null;
if (kind === 'text') {
const col = column as Extract<typeof column, { kind?: 'text' }>;
if (col.render) {
const r = col.render(item);
textTitle = r.title;
textSubtitle = r.subtitle ?? null;
} else {
const v = item[column.key];
textTitle = v == null ? '' : String(v);
}
}
// Pill column ─────────────────────────────────────────────────────────────
let pillLabel: string | null = null;
let pillClass: string | null = null;
if (kind === 'pill') {
const col = column as Extract<typeof column, { kind: 'pill' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | undefined);
if (raw) {
const variant = col.pillVariants[raw];
if (variant) {
pillLabel = variant.label;
pillClass = variant.class;
} else {
pillLabel = raw;
pillClass = 'pill-draft'; // graceful fallback
}
}
}
// Relative-date column ────────────────────────────────────────────────────
let relText: string | null = null;
let relEmpty: string | null = null;
if (kind === 'relative-date') {
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | null | undefined);
if (raw) {
relText = relativeTime(raw);
} else {
relEmpty = col.emptyFallback ?? '—';
}
}
// Number column ───────────────────────────────────────────────────────────
let numberText: string | null = null;
if (kind === 'number') {
const col = column as Extract<typeof column, { kind: 'number' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as number | null | undefined);
numberText = raw == null ? '—' : String(raw);
}
// Tag-list column ─────────────────────────────────────────────────────────
let tags: string[] = [];
if (kind === 'tag-list') {
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
tags = col.valueOf(item);
}
---
{kind === 'text' && (
<div class="bs-cell-text">
<span class:list={['bs-cell-title', { primary: column.primary }]}>{textTitle}</span>
{textSubtitle && <span class="bs-cell-subtitle">{textSubtitle}</span>}
</div>
)}
{kind === 'pill' && (
pillLabel
? <span class:list={['bs-pill', pillClass]}>{pillLabel}</span>
: <span class="bs-cell-empty">—</span>
)}
{kind === 'relative-date' && (
relText
? <span class="bs-cell-rel">{relText}</span>
: <span class="bs-cell-empty">{relEmpty}</span>
)}
{kind === 'number' && (
<span class="bs-cell-number">{numberText}</span>
)}
{kind === 'tag-list' && (
tags.length > 0
? <ul class="bs-cell-tags">
{tags.map(t => <li class="bs-tag">{t}</li>)}
</ul>
: <span class="bs-cell-empty">—</span>
)}

View file

@ -0,0 +1,205 @@
---
/* ---------------------------------------------------------------------------
* ResourceListView — shared list rendering for every Backstage resource.
*
* Reads URL state (?filter, ?q, ?page) and derives:
* - active filter (with isDefault fallback)
* - active column set (columnsByFilter override → columns)
* - filtered + searched + sorted + paginated row set
*
* Rows are full anchor elements pointing at ?edit=<id> so the table is
* fully keyboard-navigable and works without JS. The panel that consumes
* the edit param ships in step 6.
* ------------------------------------------------------------------------- */
import ListCell from './ListCell.astro';
import type { Column, Resource, ResourceGroup } from '../resource-types';
interface Props {
resource: Resource;
groups: ResourceGroup[];
}
const { resource, groups } = Astro.props;
const url = Astro.url;
// ── Resolve state from URL ────────────────────────────────────────────────
const filters = resource.list.filters ?? [];
const defaultFilterKey =
filters.find((f) => f.isDefault)?.key ?? filters[0]?.key ?? 'all';
const filterKey = url.searchParams.get('filter') ?? defaultFilterKey;
const activeFilter = filters.find((f) => f.key === filterKey);
const search = (url.searchParams.get('q') ?? '').trim();
const pageParam = Number(url.searchParams.get('page') ?? '1');
const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
const pageSize = resource.list.pageSize ?? 25;
// ── Load + transform ──────────────────────────────────────────────────────
const queried = await resource.list.queryFn();
const allItems = (Array.isArray(queried) ? queried : []) as Record<string, unknown>[];
const filtered = activeFilter
? allItems.filter((item) => activeFilter.predicate(item))
: allItems;
const searched = search && resource.list.search
? filtered.filter((item) => {
const q = search.toLowerCase();
return resource.list.search!.fields.some((field) => {
const v = item[field as string];
return typeof v === 'string' && v.toLowerCase().includes(q);
});
})
: filtered;
const sort = resource.list.defaultSort;
const sorted = sort
? [...searched].sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
if (av === bv) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : 1;
return sort.direction === 'desc' ? -cmp : cmp;
})
: searched;
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const page = Math.min(requestedPage, totalPages);
const start = (page - 1) * pageSize;
const pageItems = sorted.slice(start, start + pageSize);
// ── Resolve column set (columnsByFilter override → columns) ───────────────
const columns: Column<Record<string, unknown>>[] =
(resource.list.columnsByFilter && resource.list.columnsByFilter[filterKey]) ??
resource.list.columns;
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
// ── Group eyebrow ─────────────────────────────────────────────────────────
const group = groups.find((g) => g.key === resource.groupKey);
// ── Helper: build a query string preserving the other params ──────────────
function withParams(overrides: Record<string, string | number | null>): string {
const next = new URLSearchParams(url.searchParams);
for (const [k, v] of Object.entries(overrides)) {
if (v == null) next.delete(k);
else next.set(k, String(v));
}
const s = next.toString();
return s ? `${url.pathname}?${s}` : url.pathname;
}
const showNewButton = resource.form !== null;
const hasItems = allItems.length > 0;
const hasMatches = pageItems.length > 0;
---
<section class="bs-list">
<!-- ── Page header ─────────────────────────────────────────────── -->
<header class="bs-list-header">
<div class="bs-list-heading">
{group && <p class="bs-list-eyebrow">{group.label}</p>}
<h1 class="bs-list-title">{resource.pluralLabel}</h1>
{resource.description && <p class="bs-list-desc">{resource.description}</p>}
</div>
{showNewButton && (
<a href={withParams({ new: '1', edit: null })} class="bs-list-new label-sm">
+ New {resource.singularLabel.toLowerCase()}
</a>
)}
</header>
<!-- ── Toolbar: search + filter chips ──────────────────────────── -->
{(resource.list.search || filters.length > 0) && (
<div class="bs-toolbar">
{resource.list.search && (
<form method="get" action={url.pathname} class="bs-search-form" role="search">
{/* Preserve the active filter when submitting a search */}
{filterKey !== defaultFilterKey && (
<input type="hidden" name="filter" value={filterKey} />
)}
<input
type="search"
name="q"
class="bs-search-input"
placeholder={resource.list.search.placeholder}
value={search}
aria-label={resource.list.search.placeholder}
/>
</form>
)}
{filters.length > 0 && (
<div class="bs-filters" role="tablist" aria-label="Filter">
{filters.map((f) => {
const isActive = f.key === filterKey;
const href = withParams({ filter: f.key === defaultFilterKey ? null : f.key, page: null });
return (
<a
href={href}
class:list={['bs-chip', { active: isActive }]}
role="tab"
aria-selected={isActive}
>
{f.label}
</a>
);
})}
</div>
)}
</div>
)}
<!-- ── Grid table ──────────────────────────────────────────────── -->
{hasMatches ? (
<div class="bs-grid" role="table" style={`--bs-grid-cols: ${gridTemplate}`}>
<div class="bs-grid-head" role="row">
{columns.map((col) => (
<div class="bs-grid-th" role="columnheader">{col.label}</div>
))}
</div>
{pageItems.map((item) => {
const id = Number(item.id);
return (
<a
href={withParams({ edit: id, new: null })}
class="bs-grid-row"
role="row"
aria-label={`Edit ${resource.singularLabel.toLowerCase()} ${id}`}
>
{columns.map((col) => (
<div class="bs-grid-td" role="cell">
<ListCell column={col} item={item} />
</div>
))}
</a>
);
})}
</div>
) : (
<p class="bs-list-empty">
{hasItems
? 'No items match the current filters.'
: `No ${resource.pluralLabel.toLowerCase()} yet.`}
</p>
)}
<!-- ── Pagination (only when paged) ────────────────────────────── -->
{totalPages > 1 && (
<nav class="bs-pagination" aria-label="Pagination">
{page > 1 ? (
<a class="bs-page-link" href={withParams({ page: page - 1 })}>← Previous</a>
) : (
<span class="bs-page-link disabled" aria-hidden="true">← Previous</span>
)}
<span class="bs-page-status">Page {page} of {totalPages}</span>
{page < totalPages ? (
<a class="bs-page-link" href={withParams({ page: page + 1 })}>Next →</a>
) : (
<span class="bs-page-link disabled" aria-hidden="true">Next →</span>
)}
</nav>
)}
</section>

View file

@ -2,38 +2,99 @@
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* /admin/preview — temporary smoke route for the Backstage shell. * /admin/preview — temporary smoke route for the Backstage shell.
* *
* Mounts AdminLayout against the resource registry so the chrome can be * Defines a one-off dispatches resource inline so the list view can be
* visually verified before any resources are registered. Deleted in step 11 * visually verified before the real resources land in steps 810.
* once the new admin replaces the old. * Deleted in step 11 once the new admin replaces the old.
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
import AdminLayout from '../../admin/components/AdminLayout.astro'; import AdminLayout from '../../admin/components/AdminLayout.astro';
import { groups } from '../../admin/resources'; import ResourceListView from '../../admin/components/ResourceListView.astro';
import { getAllDispatchesForAdmin } from '../../lib/db';
import type { Resource, ResourceGroup } from '../../admin/resource-types';
const user = Astro.locals.user; const user = Astro.locals.user;
if (user.role !== 'fenja') { if (user.role !== 'fenja') {
return Astro.redirect('/'); 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.
const dispatchesPreview: Resource = {
key: 'dispatches',
label: 'Dispatches',
pluralLabel: 'Dispatches',
singularLabel: 'Dispatch',
groupKey: 'publishing',
description: 'Updates, decisions, notes — the public record of pilot progress.',
list: {
queryFn: () => getAllDispatchesForAdmin() as unknown as Record<string, unknown>[],
columns: [
{
key: 'title',
label: 'Title',
primary: true,
width: '2fr',
render: (item) => ({
title: String(item.title ?? ''),
subtitle: `${item.author_name ?? 'Unknown'}`,
}),
},
{
key: 'kind',
label: 'Kind',
kind: 'pill',
width: '140px',
pillVariants: {
decision: { label: 'Decision', class: 'pill-decision' },
update: { label: 'Update', class: 'pill-update' },
note: { label: 'Note', class: 'pill-note' },
behind_the_scenes: { label: 'Behind the scenes', class: 'pill-bts' },
},
},
{
key: 'status',
label: 'Status',
kind: 'pill',
width: '110px',
pillVariants: {
draft: { label: 'Draft', class: 'pill-draft' },
published: { label: 'Published', class: 'pill-published' },
archived: { label: 'Archived', class: 'pill-archived' },
},
},
{
key: 'updated_at',
label: 'Updated',
kind: 'relative-date',
width: '110px',
emptyFallback: '—',
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'published', label: 'Published', predicate: (i) => i.status === 'published' },
{ key: 'drafts', label: 'Drafts', predicate: (i) => i.status === 'draft' },
{ key: 'archived', label: 'Archived', predicate: (i) => i.status === 'archived' },
],
search: {
placeholder: 'Search by title or body…',
fields: ['title', 'body'],
},
defaultSort: { key: 'updated_at', direction: 'desc' },
pageSize: 10,
},
form: { fields: [] }, // placeholder — real form lands in step 8
ops: {},
};
const previewGroups: ResourceGroup[] = [
{ key: 'publishing', label: 'Publishing', resources: [dispatchesPreview] },
{ key: 'council', label: 'The council', resources: [] },
{ key: 'system', label: 'System', resources: [] },
];
--- ---
<AdminLayout title="Backstage preview" groups={groups}> <AdminLayout title="Backstage preview" groups={previewGroups} activeResourceKey="dispatches">
<article class="bs-preview"> <ResourceListView resource={dispatchesPreview} groups={previewGroups} />
<p class="eyebrow body-sm">Step 4 · shell only</p>
<h1 class="page-title heading-lg">Backstage</h1>
<p class="section-note body-md">
No resources are registered yet — the sidebar will populate in steps 810.
This route exists so the layout, topbar, and sidebar tokens can be
reviewed in isolation.
</p>
</article>
</AdminLayout> </AdminLayout>
<style>
.bs-preview {
max-width: 44rem;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>