diff --git a/src/admin/admin.css b/src/admin/admin.css index 5f3196d..08bbaff 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -539,3 +539,347 @@ 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; } +} diff --git a/src/admin/components/ListCell.astro b/src/admin/components/ListCell.astro new file mode 100644 index 0000000..1c669f1 --- /dev/null +++ b/src/admin/components/ListCell.astro @@ -0,0 +1,112 @@ +--- +/* --------------------------------------------------------------------------- + * Internal cell renderer for ResourceListView. + * + * One Column kind per branch. Branches must stay exhaustive over Column + * — 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>; + item: Record; +} + +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; + 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; + 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; + 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; + 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; + tags = col.valueOf(item); +} +--- + +{kind === 'text' && ( +
+ {textTitle} + {textSubtitle && {textSubtitle}} +
+)} + +{kind === 'pill' && ( + pillLabel + ? {pillLabel} + : +)} + +{kind === 'relative-date' && ( + relText + ? {relText} + : {relEmpty} +)} + +{kind === 'number' && ( + {numberText} +)} + +{kind === 'tag-list' && ( + tags.length > 0 + ?
    + {tags.map(t =>
  • {t}
  • )} +
+ : +)} diff --git a/src/admin/components/ResourceListView.astro b/src/admin/components/ResourceListView.astro new file mode 100644 index 0000000..c67e0f3 --- /dev/null +++ b/src/admin/components/ResourceListView.astro @@ -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= 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[]; + +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>[] = + (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 { + 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; +--- + +
+ +
+
+ {group &&

{group.label}

} +

{resource.pluralLabel}

+ {resource.description &&

{resource.description}

} +
+ {showNewButton && ( + + + New {resource.singularLabel.toLowerCase()} + + )} +
+ + + {(resource.list.search || filters.length > 0) && ( +
+ {resource.list.search && ( + + )} + {filters.length > 0 && ( +
+ {filters.map((f) => { + const isActive = f.key === filterKey; + const href = withParams({ filter: f.key === defaultFilterKey ? null : f.key, page: null }); + return ( + + {f.label} + + ); + })} +
+ )} +
+ )} + + + {hasMatches ? ( +
+
+ {columns.map((col) => ( +
{col.label}
+ ))} +
+ {pageItems.map((item) => { + const id = Number(item.id); + return ( + + {columns.map((col) => ( +
+ +
+ ))} +
+ ); + })} +
+ ) : ( +

+ {hasItems + ? 'No items match the current filters.' + : `No ${resource.pluralLabel.toLowerCase()} yet.`} +

+ )} + + + {totalPages > 1 && ( + + )} +
diff --git a/src/pages/admin/preview.astro b/src/pages/admin/preview.astro index 9edd124..dc6638a 100644 --- a/src/pages/admin/preview.astro +++ b/src/pages/admin/preview.astro @@ -2,38 +2,99 @@ /* --------------------------------------------------------------------------- * /admin/preview — temporary smoke route for the Backstage shell. * - * Mounts AdminLayout against the resource registry so the chrome can be - * visually verified before any resources are registered. Deleted in step 11 - * once the new admin replaces the old. + * 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. * ------------------------------------------------------------------------- */ 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; 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. +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[], + 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: [] }, +]; --- - -
-

Step 4 · shell only

-

Backstage

-

- No resources are registered yet — the sidebar will populate in steps 8–10. - This route exists so the layout, topbar, and sidebar tokens can be - reviewed in isolation. -

-
+ + - -