Compare commits

..

10 commits

Author SHA1 Message Date
8bbf8568f4 feat(admin): retire old admin, add resource verifier, redirect /admin
The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.

- tests/admin-resources.test.ts: vitest-based verifier that walks
  every registered resource and asserts:
    - identity fields (key/label/plural/singular/groupKey)
    - list.queryFn, action.handler, ops.* members are functions
    - column kinds are in the registered set (text/pill/relative-date/
      number/tag-list); same for columnsByFilter overrides
    - field kinds are in the registered set (11 kinds)
    - embed.component is in the registered set (pulse-sub-form)
    - resource keys are unique, action keys are unique per resource
    - at most one filter is isDefault
    - groupKey resolves to a real group
    - review-mode resources have at least one action
    - ops.create requires a non-null form
  87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
  ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
  UserEditTab — all replaced by the resource configs).

Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:48 +02:00
18d371b368 feat(admin): activity resource (read-only debug feed)
Last resource lands. /admin/activity tails the activity table —
votes, RSVPs, office-hour bookings, roadmap ships, pulse opens —
with last-7-days / last-30-days filters. Pure read view: no form,
no summary, no ops.

src/admin/components/ResourceListView.astro: rows fall back to <div>
when the resource has no panel pathway (form: null AND no summary).
Activity rows aren't clickable now — previously they'd dirty the URL
with a ?edit= that resolved to nothing.

The registry is complete: 7 resources across 3 groups, matching the
sidebar layout described in the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:38:11 +02:00
e9a986d484 feat(admin): council-group resources (users, invitations, join requests)
Three more resources land. /admin/users replaces the old participants
tab, /admin/invitations replaces the old invites tab, /admin/join_requests
replaces the read-only join queue.

- src/admin/resources/users.ts ("People"): single resource for all users,
  filter chips swap visible columns (council shows member_number +
  focus_tags; pilots/team show role + last_seen_at). Form fields are
  conditional — title / pull_quote / focus_tags / cab_joined_date /
  member_number render only when role === cab. No ops.create (users
  come via invites); deactivateUser is the delete handler.
- src/admin/resources/invitations.ts: form-for-create, summary-for-view.
  Create generates a token via generateInviteToken(), stores its hash,
  surfaces the magic link as a one-shot ?invite_url= block in the panel.
  Revoke is an action (sets expires_at = now); the row stays for audit.
- src/admin/resources/join-requests.ts: form: null, review-mode panel
  with the user's summary + approve_as_cab / decline actions.

Plumbing to support the above:
- src/admin/resource-types.ts: new Resource.summary callback (read-only
  field pairs for review panels); OpContext.result lets ops surface
  ActionResults (e.g. invite-link).
- src/admin/components/ResourceEditPanel.astro: review mode when an
  existing item is shown and resource.summary is defined; renders the
  ?invite_url= block above the summary with a copy-to-clipboard button.
- src/admin/components/ResourceListView.astro: "+ New" suppressed when
  ops.create is undefined.
- src/pages/admin/[resource].astro: captures ctx.result and action
  handler return values, propagates them via &invite_url=...; routes to
  the list view (not the row) when an action removes the item.
- src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest,
  getInviteById.

Deviation from the original delta: no approve_as_pilot action and no
invite-link result on join-request approval. The existing
join_requests schema only stores user_id — requests come from
already-authenticated pilots asking for a CAB upgrade, not from
strangers needing an invite. The schema change for stranger sign-ups
is left for a future follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:32:26 +02:00
dd9ea68fab feat(admin): publishing-group resources (dispatches, roadmap, events)
The Backstage rebuild's first three resource configs. /admin/dispatches,
/admin/roadmap, and /admin/events now resolve through the dynamic route
with full list views, edit panels, and the publish/archive actions.

- src/admin/resources/dispatches.ts — kind/status/author/excerpt/body
  fields, embedded pulse sub-form (pulse_question + multi-text options +
  opens/closes datetimes), publish/archive actions, notifyCount on
  drafts so the sidebar lights up terracotta until they ship.
- src/admin/resources/roadmap.ts — title/description/status/target/
  display_order/metadata_text plus a multi-select-async for attributed
  members. ops.update writes via setRoadmapAttributions() after the
  basic save so the pivot table stays in sync.
- src/admin/resources/events.ts — full event fields; ops.create
  auto-generates a unique slug from the title when blank.
- src/admin/embeds/PulseSubForm.astro — reads the dispatch's current
  pulse via getPulseById(), renders question + options + opens/closes.
  Pulses follow their parent dispatch's lifecycle (draft → open on
  publish, → closed on archive); no status field of their own.
- src/admin/components/ResourceEditPanel.astro — dispatches on
  embed.component, renders PulseSubForm for 'pulse-sub-form'.
- src/admin/resource-types.ts — renamed column .valueOf to .value
  (collision with Object.prototype.valueOf was breaking TS structural
  matching); OpContext now optionally carries the raw FormData so
  resources with sub-forms can read embed fields.
- src/pages/admin/[resource].astro — passes formData into opCtx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:24:13 +02:00
3aaa21e6af feat(admin): /admin/[resource] dynamic route + POST dispatch
The production route every Backstage resource lives under. Resolves
the resource from the URL segment against the registry, gates on
user.role === 'fenja', and renders the AdminLayout shell with the
ResourceListView + (optionally) ResourceEditPanel.

POST dispatch keyed by _action:
- save: parses formdata per field.kind (multi-text/multi-select-async
  use getAll(), number coerces, others coerce to string), validates
  via validateForResource, then routes to ops.update(id) when
  ?edit=<id> is set or ops.create() when ?new=1. Redirects with
  ?msg=saved | ?msg=created. On failure, re-renders the panel with
  errors + the submitted values.
- delete: calls ops.delete(id), redirects with ?msg=deleted.
- <action.key>: looks up the action in resource.actions and runs its
  handler, redirects with ?msg=action_<key>.

404s when the resource key isn't in the registry — most keys won't
resolve until steps 8-10 land. A small .bs-flash banner above the
list surfaces the ?msg= text (or the error message after a failed
save).

Old /admin (?tab=...) continues to work alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:15:17 +02:00
09a10061b2 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>
2026-05-12 16:10:39 +02:00
cc9332e6e2 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>
2026-05-12 16:05:15 +02:00
dd7215d828 feat(admin): AdminLayout shell + empty resource registry
Two-pane Backstage chrome: sticky topbar with the wordmark + " /
Backstage" lockup and a "Back to the portal" link, plus a left
sidebar that walks the resource registry and renders grouped
links with active-state and count badges.

- src/admin/components/AdminLayout.astro — the shell. Pre-resolves
  list-counts and notify-counts per resource so the sidebar can
  render badges without async work in markup. Renders an empty
  state until resources land.
- src/admin/resources/index.ts — empty registry stub. Three groups
  declared (publishing, council, system); resources populated in
  steps 8–10.
- src/admin/admin.css — Backstage tokens (--admin-sidebar-bg,
  --admin-active-accent, etc.) and the shell styles (bs-topbar,
  bs-sidebar, bs-resource, bs-count). Mobile collapses the sidebar
  above the main pane.
- src/pages/admin/preview.astro — temporary smoke-test route at
  /admin/preview. Deleted in step 11 when the new admin replaces
  the old one.

Old /admin (?tab=…) is untouched and continues to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:00:57 +02:00
ea056fff7b feat(admin): add Resource type definitions + form validator
Establishes the load-bearing type surface for the Backstage rebuild:

- src/admin/resource-types.ts — discriminated unions for Field, Column,
  Filter, Action, plus the top-level Resource and ResourceGroup. Strict
  per the maintainability bar: a config object missing a required key
  fails TypeScript.
- src/admin/validate.ts — validateForResource() derives validation
  from the field definitions (required, maxLength, multi-text min/max,
  number bounds, date parse, visibleWhen-aware).
- tests/admin-validate.test.ts — 8 cases locking the validator API:
  required, maxLength, visibleWhen skip & reveal, multi-text bounds,
  number bounds, all-valid, form-null short-circuit.

No consumers yet. Next commit pulls these into admin.css and the
shared layout components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:56:42 +02:00
103bfa2f0c refactor(admin): extract inline styles to src/admin/admin.css
Move the ~300-line <style> block from src/pages/admin/index.astro
into a dedicated stylesheet, imported from the page frontmatter.
No rule changes — verbatim extraction so the existing admin UI
continues to render identically.

This is the first commit of the Backstage rebuild: it establishes
the shared admin stylesheet that the resource-pattern components
will consume in subsequent steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:53:10 +02:00
39 changed files with 5112 additions and 1982 deletions

1417
src/admin/admin.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
---
/* ---------------------------------------------------------------------------
* AdminLayout — the two-pane Backstage shell.
*
* Top strip: wordmark + " / Backstage" on the left, "Back to the portal"
* link on the right.
* Left: grouped resource sidebar with active-state and count badges.
* Right: slot for the current resource view (list or panel).
*
* Standalone from AppLayout deliberately — the member-facing portal and
* the admin surface have different chrome.
* ------------------------------------------------------------------------- */
import BaseLayout from '../../layouts/BaseLayout.astro';
import '../admin.css';
import type { ResourceGroup, Resource } from '../resource-types';
interface Props {
title: string;
/** Which resource is currently active — used for sidebar highlighting. */
activeResourceKey?: string;
/** The registry to render in the sidebar. */
groups: ResourceGroup[];
}
const { title, activeResourceKey, groups } = Astro.props;
// Pre-compute list-counts and notify-counts for every registered resource,
// so the sidebar can render badges without doing async work in markup.
type SidebarEntry = {
resource: Resource;
count: number;
notify: number;
};
async function loadEntries(group: ResourceGroup): Promise<SidebarEntry[]> {
return Promise.all(
group.resources.map(async (resource): Promise<SidebarEntry> => {
const items = await resource.list.queryFn();
const arr = Array.isArray(items) ? items : [];
return {
resource,
count: arr.length,
notify: resource.notifyCount ? resource.notifyCount.count(arr) : 0,
};
}),
);
}
const groupedEntries = await Promise.all(
groups.map(async (g) => ({ group: g, entries: await loadEntries(g) })),
);
const hasAnyResources = groupedEntries.some((g) => g.entries.length > 0);
---
<BaseLayout title={title}>
<div class="backstage">
<!-- ── Top strip ──────────────────────────────────────────────── -->
<header class="bs-topbar" role="banner">
<a href="/admin" class="bs-brand" aria-label="Backstage — home">
<img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" />
<span class="bs-brand-sep" aria-hidden="true">·</span>
<span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span>
<span class="bs-brand-slash" aria-hidden="true">/</span>
<span class="bs-brand-backstage">Backstage</span>
</a>
<a href="/pulse" class="bs-back-link label-sm">← Back to the portal</a>
</header>
<!-- ── Two-pane body ──────────────────────────────────────────── -->
<div class="bs-body">
<!-- Sidebar -->
<nav class="bs-sidebar" aria-label="Resource navigation">
{hasAnyResources ? (
<ul class="bs-groups">
{groupedEntries.map(({ group, entries }) => (
entries.length > 0 && (
<li class="bs-group">
<p class="bs-group-label">{group.label}</p>
<ul class="bs-resources">
{entries.map(({ resource, count, notify }) => {
const isActive = activeResourceKey === resource.key;
return (
<li>
<a
href={`/admin/${resource.key}`}
class:list={['bs-resource', { active: isActive }]}
aria-current={isActive ? 'page' : undefined}
>
<span class="bs-resource-label">{resource.label}</span>
{count > 0 && (
<span class:list={['bs-count', { notify: notify > 0 }]}>
{notify > 0 ? notify : count}
</span>
)}
</a>
</li>
);
})}
</ul>
</li>
)
))}
</ul>
) : (
<p class="bs-sidebar-empty">No resources registered yet.</p>
)}
</nav>
<!-- Main pane -->
<main class="bs-main" role="main">
<slot />
</main>
</div>
</div>
</BaseLayout>

View file

@ -0,0 +1,57 @@
---
/* ---------------------------------------------------------------------------
* FieldRenderer — dispatches on field.kind to the right input component
* and wraps it with label + helper + error.
*
* Branches must stay exhaustive; the `never` fallback flags any unhandled
* Field kind at compile time.
* ------------------------------------------------------------------------- */
import TextField from './fields/TextField.astro';
import TextareaField from './fields/TextareaField.astro';
import MarkdownField from './fields/MarkdownField.astro';
import SelectField from './fields/SelectField.astro';
import SelectAsyncField from './fields/SelectAsyncField.astro';
import MultiSelectAsyncField from './fields/MultiSelectAsyncField.astro';
import MultiTextField from './fields/MultiTextField.astro';
import DateField from './fields/DateField.astro';
import DatetimeField from './fields/DatetimeField.astro';
import NumberField from './fields/NumberField.astro';
import ReadonlyField from './fields/ReadonlyField.astro';
import type { Field } from '../resource-types';
interface Props {
field: Field;
value: unknown;
error?: string;
item: Record<string, unknown> | null;
}
const { field, value, error, item } = Astro.props;
---
<div class="bs-field" data-field={field.key}>
<label class="bs-label" for={`f-${field.key}`}>
{field.label}
{field.required && <span class="bs-required" aria-hidden="true">*</span>}
</label>
{field.kind === 'text' && <TextField field={field} value={value} />}
{field.kind === 'textarea' && <TextareaField field={field} value={value} />}
{field.kind === 'markdown' && <MarkdownField field={field} value={value} />}
{field.kind === 'select' && <SelectField field={field} value={value} />}
{field.kind === 'select-async' && <SelectAsyncField field={field} value={value} />}
{field.kind === 'multi-select-async' && <MultiSelectAsyncField field={field} value={value} />}
{field.kind === 'multi-text' && <MultiTextField field={field} value={value} />}
{field.kind === 'date' && <DateField field={field} value={value} />}
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
{field.kind === 'number' && <NumberField field={field} value={value} />}
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
{field.helperText && (
<p class="bs-helper">{field.helperText}</p>
)}
{error && (
<p class="bs-field-error" role="alert">{error}</p>
)}
</div>

View file

@ -0,0 +1,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.value ? col.value(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.value ? col.value(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.value ? col.value(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.value(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,307 @@
---
/* ---------------------------------------------------------------------------
* 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 PulseSubForm from '../embeds/PulseSubForm.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;
const isCreate = item === null;
// Review mode = the panel is showing an existing item AND either the resource
// has no form, OR it has a summary that should be preferred over the form
// (e.g. invitations are write-once: form is for create, summary is for edit).
const isReviewMode =
!isCreate &&
(resource.form === null || resource.summary !== undefined);
const singular = resource.singularLabel.toLowerCase();
const title = isReviewMode
? `Review ${singular}`
: isCreate
? `New ${singular}`
: `Edit ${singular}`;
// In review mode, the resource MUST have a summary function — that's what
// fills the panel body when the form is suppressed.
if (isReviewMode && !resource.summary) {
throw new Error(
`ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`,
);
}
// And the create path needs a form.
if (isCreate && !resource.form) {
throw new Error(
`ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`,
);
}
// 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
? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx))
: [];
const embeds = resource.form?.embeds ?? [];
// Review-mode summary
const summaryEntries = isReviewMode && item
? resource.summary!(item)
: [];
// One-shot invite link surfaced after create/action — read from URL
const inviteUrl = Astro.url.searchParams.get('invite_url');
// 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">
{inviteUrl && (
<section class="bs-invite-result" data-invite-block>
<p class="bs-invite-result-label">Magic link — copy and send now. It will not be shown again.</p>
<div class="bs-invite-link-row">
<code class="bs-invite-link" id="bs-invite-link">{inviteUrl}</code>
<button type="button" class="bs-copy-btn" data-copy-target="#bs-invite-link">Copy</button>
</div>
</section>
)}
{isReviewMode ? (
<dl class="bs-summary">
{summaryEntries.map((entry) => (
<div class="bs-summary-row">
<dt class="bs-summary-label">{entry.label}</dt>
<dd class="bs-summary-value">{entry.value}</dd>
</div>
))}
</dl>
) : (
<>
{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);
if (!show) return null;
return (
<section class="bs-embed" data-embed={embed.key}>
<h3 class="bs-embed-title">{embed.title}</h3>
{embed.component === 'pulse-sub-form' && (
<PulseSubForm item={item} />
)}
</section>
);
})}
</>
)}
</div>
<footer class="bs-panel-foot">
<div class="bs-panel-foot-left">
{!isReviewMode && (
<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 && !isReviewMode && 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;
}
});
});
});
// ── Copy-to-clipboard for invite-link blocks ─────────────────────────────
document.querySelectorAll<HTMLButtonElement>('.bs-copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const sel = btn.getAttribute('data-copy-target');
const target = sel ? document.querySelector(sel) : null;
const text = target?.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
const orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = orig; }, 1400);
} catch {
// clipboard blocked — leave the link visible for manual copy
}
});
});
// ── MultiTextField add / remove ──────────────────────────────────────────
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');
const addBtn = root.querySelector<HTMLButtonElement>('.bs-multitext-add');
if (!rows || !addBtn) return;
const fieldKey = root.dataset.multitext ?? 'option';
const min = Number(root.dataset.min ?? '1');
const max = Number(root.dataset.max ?? '10');
function updateButtons() {
const inputs = rows!.querySelectorAll<HTMLElement>('.bs-multitext-row');
addBtn!.disabled = inputs.length >= max;
inputs.forEach((row) => {
const rm = row.querySelector<HTMLButtonElement>('.bs-multitext-remove');
if (rm) rm.disabled = inputs.length <= min;
});
}
rows.addEventListener('click', (e) => {
const rm = (e.target as HTMLElement | null)?.closest('.bs-multitext-remove');
if (!rm) return;
const row = rm.closest('.bs-multitext-row');
if (row && rows.querySelectorAll('.bs-multitext-row').length > min) {
row.remove();
updateButtons();
}
});
addBtn.addEventListener('click', () => {
const inputs = rows.querySelectorAll('.bs-multitext-row');
if (inputs.length >= max) return;
const row = document.createElement('div');
row.className = 'bs-multitext-row';
row.innerHTML =
`<input type="text" name="${fieldKey}" class="bs-input" value="" placeholder="Option ${inputs.length + 1}" />` +
`<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>`;
rows.appendChild(row);
updateButtons();
});
updateButtons();
});
</script>

View file

@ -0,0 +1,211 @@
---
/* ---------------------------------------------------------------------------
* 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 && resource.ops.create !== undefined;
const hasItems = allItems.length > 0;
const hasMatches = pageItems.length > 0;
// A row is only clickable when the panel has something to render — either an
// editable form or a review summary. Pure debug resources (activity) skip the
// anchor wrapper so clicks don't dirty the URL with a ?edit= that goes nowhere.
const rowsClickable = resource.form !== null || resource.summary !== undefined;
---
<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);
const Tag = rowsClickable ? 'a' : 'div';
const linkProps = rowsClickable
? {
href: withParams({ edit: id, new: null }),
'aria-label': `Open ${resource.singularLabel.toLowerCase()} ${id}`,
}
: {};
return (
<Tag class="bs-grid-row" role="row" {...linkProps}>
{columns.map((col) => (
<div class="bs-grid-td" role="cell">
<ListCell column={col} item={item} />
</div>
))}
</Tag>
);
})}
</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

@ -0,0 +1,29 @@
---
import type { DateField } from '../../resource-types';
interface Props {
field: DateField;
value: unknown;
}
const { field, value } = Astro.props;
// Coerce ISO datetime → "YYYY-MM-DD" for the <input type="date"> control.
function toDateInputValue(v: unknown): string {
if (v == null || v === '') return '';
const s = String(v);
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : '';
}
const v = toDateInputValue(value);
---
<input
type="date"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -0,0 +1,30 @@
---
import type { DatetimeField } from '../../resource-types';
interface Props {
field: DatetimeField;
value: unknown;
}
const { field, value } = Astro.props;
// Coerce ISO datetime (which may be "YYYY-MM-DD HH:MM:SS" SQLite-style or
// "YYYY-MM-DDTHH:mm:ss[Z|+offset]") to the "YYYY-MM-DDTHH:mm" the input wants.
function toDatetimeLocal(v: unknown): string {
if (v == null || v === '') return '';
const s = String(v).replace(' ', 'T');
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
return m ? m[1] : '';
}
const v = toDatetimeLocal(value);
---
<input
type="datetime-local"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -0,0 +1,41 @@
---
/* ---------------------------------------------------------------------------
* MarkdownField — textarea with a Preview toggle.
*
* The preview panel is rendered server-side once with the current value, so
* it's available the moment the toggle flips even without a network call.
* Toggling on does NOT re-render — that's a Phase 2 enhancement. The toggle
* itself is keyboard-accessible.
* ------------------------------------------------------------------------- */
import { renderMd } from '../../../lib/markdown';
import type { MarkdownField } from '../../resource-types';
interface Props {
field: MarkdownField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
const previewHtml = v ? renderMd(v) : '<p class="bs-md-empty">Nothing to preview yet.</p>';
---
<div class="bs-md" data-md-field={field.key}>
<div class="bs-md-toolbar">
<button type="button" class="bs-md-tab is-active" data-md-mode="edit">Write</button>
<button type="button" class="bs-md-tab" data-md-mode="preview">Preview</button>
</div>
<textarea
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-textarea bs-md-input"
rows={field.rows ?? 14}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
>{v}</textarea>
<div class="bs-md-preview" hidden set:html={previewHtml} />
</div>

View file

@ -0,0 +1,47 @@
---
/* ---------------------------------------------------------------------------
* MultiSelectAsyncField — checkbox grid for picking multiple options.
*
* Submits as repeated form values under field.key[] (browser default for
* multiple checkboxes with the same name). The route handler in step 7
* normalises the array form via getAll().
* ------------------------------------------------------------------------- */
import type { MultiSelectAsyncField } from '../../resource-types';
interface Props {
field: MultiSelectAsyncField;
value: unknown;
}
const { field, value } = Astro.props;
const options = await field.loadOptions();
const selected = new Set<string>();
if (Array.isArray(value)) {
for (const v of value) selected.add(String(v));
}
---
<fieldset class="bs-multiselect" disabled={field.readOnly}>
<legend class="bs-visually-hidden">{field.label}</legend>
{options.length === 0 && (
<p class="bs-multiselect-empty">No options available.</p>
)}
{options.map(opt => {
const id = `f-${field.key}-${opt.value}`;
const isChecked = selected.has(String(opt.value));
return (
<label class="bs-multiselect-row" for={id}>
<input
type="checkbox"
id={id}
name={field.key}
value={String(opt.value)}
checked={isChecked}
/>
<span>{opt.label}</span>
</label>
);
})}
</fieldset>

View file

@ -0,0 +1,53 @@
---
/* ---------------------------------------------------------------------------
* MultiTextField — N text inputs with add/remove, used for pulse options.
*
* Submits as repeated form values under field.key. Initial input count is
* Math.max(minItems, value.length). Add/remove buttons are managed by a
* small client script attached to the panel.
* ------------------------------------------------------------------------- */
import type { MultiTextField } from '../../resource-types';
interface Props {
field: MultiTextField;
value: unknown;
}
const { field, value } = Astro.props;
const min = field.minItems ?? 1;
const max = field.maxItems ?? 10;
const initialValues: string[] = Array.isArray(value)
? value.map((v) => (v == null ? '' : String(v)))
: [];
const initialCount = Math.max(min, initialValues.length, 1);
while (initialValues.length < initialCount) initialValues.push('');
---
<div
class="bs-multitext"
data-multitext={field.key}
data-min={min}
data-max={max}
>
<div class="bs-multitext-rows">
{initialValues.map((v, i) => (
<div class="bs-multitext-row">
<input
type="text"
name={field.key}
class="bs-input"
value={v}
placeholder={field.placeholderEach ?? `Option ${i + 1}`}
/>
<button
type="button"
class="bs-multitext-remove"
aria-label="Remove option"
>×</button>
</div>
))}
</div>
<button type="button" class="bs-multitext-add">+ Add option</button>
</div>

View file

@ -0,0 +1,24 @@
---
import type { NumberField } from '../../resource-types';
interface Props {
field: NumberField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
---
<input
type="number"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
min={field.min}
max={field.max}
step={field.step}
required={field.required}
readonly={field.readOnly}
/>

View file

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

View file

@ -0,0 +1,25 @@
---
import type { SelectAsyncField } from '../../resource-types';
interface Props {
field: SelectAsyncField;
value: unknown;
}
const { field, value } = Astro.props;
const current = value == null ? '' : String(value);
const options = await field.loadOptions();
---
<select
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-select"
required={field.required}
disabled={field.readOnly}
>
{!field.required && <option value="">—</option>}
{options.map(opt => (
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
))}
</select>

View file

@ -0,0 +1,24 @@
---
import type { SelectField } from '../../resource-types';
interface Props {
field: SelectField;
value: unknown;
}
const { field, value } = Astro.props;
const current = value == null ? '' : String(value);
---
<select
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-select"
required={field.required}
disabled={field.readOnly}
>
{!field.required && <option value="">—</option>}
{field.options.map(opt => (
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
))}
</select>

View file

@ -0,0 +1,23 @@
---
import type { TextField } from '../../resource-types';
interface Props {
field: TextField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
---
<input
type="text"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
placeholder={field.placeholder ?? ''}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
/>

View file

@ -0,0 +1,22 @@
---
import type { TextareaField } from '../../resource-types';
interface Props {
field: TextareaField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
---
<textarea
id={`f-${field.key}`}
name={field.key}
class="bs-input bs-textarea"
rows={field.rows ?? 4}
placeholder={field.placeholder ?? ''}
maxlength={field.maxLength}
required={field.required}
readonly={field.readOnly}
>{v}</textarea>

View file

@ -0,0 +1,101 @@
---
/* ---------------------------------------------------------------------------
* PulseSubForm — embedded fieldset inside the dispatch edit panel.
*
* Reads the dispatch's current pulse (if any) and renders editable fields.
* Submitted via the parent dispatch form with `pulse_*` prefixed names; the
* dispatches resource's ops.create/update read these out of ctx.formData.
*
* If pulse_question is blank on save, no pulse is attached. The status field
* intentionally isn't here — pulses follow their parent dispatch's lifecycle
* (draft → open on publish, → closed on archive).
* ------------------------------------------------------------------------- */
import { getPulseById } from '../../lib/db';
interface Props {
/** The dispatch being edited, or null when creating. */
item: Record<string, unknown> | null;
}
const { item } = Astro.props;
const pulseId = item?.pulse_id ? Number(item.pulse_id) : null;
const pulse = pulseId ? getPulseById(pulseId) : null;
const question = pulse?.question ?? '';
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
while (initialOptions.length < 2) initialOptions.push('');
function toDatetimeLocal(v: string | null | undefined): string {
if (!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] : '';
}
---
<div class="bs-pulse-embed">
<p class="bs-helper">
Attach a pulse by filling in the question and at least two options.
Leaving the question blank means no pulse on this dispatch.
Pulses open when the dispatch is published and close when it's archived.
</p>
<div class="bs-field">
<label class="bs-label" for="pulse_question">Question</label>
<input
type="text"
id="pulse_question"
name="pulse_question"
class="bs-input"
value={question}
placeholder="What should we prioritise next?"
maxlength="240"
/>
</div>
<div class="bs-field">
<label class="bs-label">Options</label>
<div class="bs-multitext" data-multitext="pulse_options" data-min="2" data-max="4">
<div class="bs-multitext-rows">
{initialOptions.map((opt, i) => (
<div class="bs-multitext-row">
<input
type="text"
name="pulse_options"
class="bs-input"
value={opt}
placeholder={`Option ${i + 1}`}
maxlength="120"
/>
<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>
</div>
<div class="bs-field-row">
<div class="bs-field">
<label class="bs-label" for="pulse_opens_at">Opens</label>
<input
type="datetime-local"
id="pulse_opens_at"
name="pulse_opens_at"
class="bs-input"
value={toDatetimeLocal(pulse?.opens_at)}
/>
</div>
<div class="bs-field">
<label class="bs-label" for="pulse_closes_at">Closes</label>
<input
type="datetime-local"
id="pulse_closes_at"
name="pulse_closes_at"
class="bs-input"
value={toDatetimeLocal(pulse?.closes_at)}
/>
</div>
</div>
</div>

313
src/admin/resource-types.ts Normal file
View file

@ -0,0 +1,313 @@
/* ---------------------------------------------------------------------------
* Resource type definitions for the Backstage admin surface.
*
* Every admin-managed entity is declared as a single Resource<T> object.
* The shared components (AdminLayout, ResourceListView, ResourceEditPanel)
* consume these objects and never know about specific entities.
*
* Adding a new entity = write a Resource config + register it. That is the
* load-bearing invariant of the rebuild.
* ------------------------------------------------------------------------- */
// ── Option (used for select / select-async / multi-select-async) ────────────
export interface Option<V = string | number> {
value: V;
label: string;
}
// ── Pill variants (status/kind columns + pill display elsewhere) ────────────
export interface PillVariant {
label: string;
/** CSS class defined in src/admin/admin.css — e.g. 'pill-published'. */
class: string;
}
export type PillVariants = Record<string, PillVariant>;
// ── Field context — passed to visibleWhen / defaultValue resolvers ──────────
export interface FieldContext {
/** Current form values keyed by field.key. */
formValues: Record<string, unknown>;
/** The item being edited, or null on create. */
item: Record<string, unknown> | null;
/** Acting admin's user id, available for current-user defaults. */
actingUserId: number;
}
// ── Fields ──────────────────────────────────────────────────────────────────
interface FieldBase {
key: string;
label: string;
required?: boolean;
helperText?: string;
/** Default for create. Can be a literal or a resolver receiving FieldContext. */
defaultValue?: unknown | ((ctx: FieldContext) => unknown);
/** Hide the field entirely when this returns false. Re-evaluated on every render. */
visibleWhen?: (ctx: FieldContext) => boolean;
/** Display the field but disable editing. */
readOnly?: boolean;
}
export interface TextField extends FieldBase {
kind: 'text';
maxLength?: number;
pattern?: RegExp;
placeholder?: string;
}
export interface TextareaField extends FieldBase {
kind: 'textarea';
rows?: number;
maxLength?: number;
placeholder?: string;
}
export interface MarkdownField extends FieldBase {
kind: 'markdown';
rows?: number;
maxLength?: number;
}
export interface SelectField extends FieldBase {
kind: 'select';
options: Option[];
}
export interface SelectAsyncField extends FieldBase {
kind: 'select-async';
loadOptions: () => Promise<Option[]> | Option[];
}
export interface MultiSelectAsyncField extends FieldBase {
kind: 'multi-select-async';
loadOptions: () => Promise<Option[]> | Option[];
}
/** Series of free-text inputs — used for pulse options (24 entries). */
export interface MultiTextField extends FieldBase {
kind: 'multi-text';
minItems?: number;
maxItems?: number;
placeholderEach?: string;
}
export interface DateField extends FieldBase {
kind: 'date';
}
export interface DatetimeField extends FieldBase {
kind: 'datetime';
}
export interface NumberField extends FieldBase {
kind: 'number';
min?: number;
max?: number;
step?: number;
}
/** Display-only — never edited; renders the value verbatim or via render(). */
export interface ReadonlyField extends FieldBase {
kind: 'readonly';
render?: (value: unknown, item: Record<string, unknown> | null) => string;
}
export type Field =
| TextField
| TextareaField
| MarkdownField
| SelectField
| SelectAsyncField
| MultiSelectAsyncField
| MultiTextField
| DateField
| DatetimeField
| NumberField
| ReadonlyField;
// ── Columns ─────────────────────────────────────────────────────────────────
interface ColumnBase {
key: string;
label: string;
/** CSS grid-template-columns track (e.g. '1.7fr', '120px'). */
width?: string;
/** Primary column gets the larger title styling. At most one per columns array. */
primary?: boolean;
/** When set, the list can be sorted by this column. */
sortable?: boolean;
}
export interface TextColumn<T> extends ColumnBase {
kind?: 'text';
/** Override the default <td>{item[key]}</td> rendering. */
render?: (item: T) => { title: string; subtitle?: string };
}
export interface PillColumn<T> extends ColumnBase {
kind: 'pill';
pillVariants: PillVariants;
/** Override which value to look up in pillVariants (default = item[key]). */
value?: (item: T) => string;
}
export interface RelativeDateColumn<T> extends ColumnBase {
kind: 'relative-date';
/** Shown when the value is null/undefined. */
emptyFallback?: string;
value?: (item: T) => string | null | undefined;
}
export interface NumberColumn<T> extends ColumnBase {
kind: 'number';
value?: (item: T) => number | null | undefined;
}
/** Compact list of pills — for focus_tags, audience, etc. */
export interface TagListColumn<T> extends ColumnBase {
kind: 'tag-list';
value: (item: T) => string[];
}
export type Column<T> =
| TextColumn<T>
| PillColumn<T>
| RelativeDateColumn<T>
| NumberColumn<T>
| TagListColumn<T>;
// ── Filters ─────────────────────────────────────────────────────────────────
export interface Filter<T> {
key: string;
label: string;
predicate: (item: T) => boolean;
isDefault?: boolean;
}
// ── Search ──────────────────────────────────────────────────────────────────
export interface SearchConfig<T> {
placeholder: string;
/** Object keys to search; coerced to string and matched case-insensitively. */
fields: (keyof T & string)[];
}
// ── Sort ────────────────────────────────────────────────────────────────────
export interface SortConfig {
key: string;
direction: 'asc' | 'desc';
}
// ── List view config ────────────────────────────────────────────────────────
export interface ListConfig<T> {
queryFn: () => T[] | Promise<T[]>;
/** Default column set. */
columns: Column<T>[];
/**
* Override columns when a specific filter is active. The key matches a
* Filter.key. Used by the Users resource (council vs pilots) to swap
* member_number/focus_tags for role/last_seen_at.
*/
columnsByFilter?: Record<string, Column<T>[]>;
filters?: Filter<T>[];
search?: SearchConfig<T>;
defaultSort?: SortConfig;
pageSize?: number;
}
// ── Embedded sub-forms (the Pulse fieldset inside dispatch edit) ────────────
export interface FormEmbed {
/** Unique key inside the parent form. */
key: string;
title: string;
/**
* Discriminator the panel uses to pick a renderer component.
* Keep this small new embed kinds are explicit additions, not generic.
*/
component: 'pulse-sub-form';
visibleWhen?: (ctx: FieldContext) => boolean;
}
// ── Form config ─────────────────────────────────────────────────────────────
export interface FormConfig {
fields: Field[];
/** Optional embedded sub-form sections (e.g. pulse inside dispatch). */
embeds?: FormEmbed[];
}
// ── Op context — passed to CRUD ops and actions ─────────────────────────────
export interface OpContext {
user: { id: number; role: string };
/**
* Raw POST FormData opt-in escape hatch for resources whose form has
* embedded sub-forms (e.g. the pulse fieldset inside dispatches). Most
* resources ignore this and work off the typed `data` argument.
*/
formData?: FormData;
/**
* Set by an op or action to surface a one-shot result on the next render
* (e.g. the magic link after an invite is created). The route handler
* reads this after the op returns and propagates it via the redirect URL.
*/
result?: ActionResult;
}
// ── CRUD operations ─────────────────────────────────────────────────────────
export interface ResourceOps<T> {
/** Returns the new item's id. */
create?: (data: Record<string, unknown>, ctx: OpContext) => number | Promise<number>;
update?: (id: number, data: Record<string, unknown>, ctx: OpContext) => void | Promise<void>;
delete?: (id: number, ctx: OpContext) => void | Promise<void>;
getById?: (id: number) => T | null | Promise<T | null>;
}
// ── Action results — surfaced inside the edit panel ─────────────────────────
/** No additional UI — just close the panel with a toast. */
export interface ActionResultToast {
kind: 'toast';
}
/** Render the generated invite link in the panel with a Copy button. */
export interface ActionResultInviteLink {
kind: 'invite-link';
url: string;
}
export type ActionResult = ActionResultToast | ActionResultInviteLink;
// ── Actions (publish, archive, approve, etc.) ───────────────────────────────
export interface ResourceAction<T> {
key: string;
label: string;
/** Hide the action when this returns false for the current item. */
visibleWhen?: (item: T) => boolean;
/** Confirm dialog text. If omitted, no confirmation is shown. */
confirmText?: string;
/** Destructive actions render in terracotta. */
destructive?: boolean;
handler: (id: number, ctx: OpContext) => ActionResult | void | Promise<ActionResult | void>;
}
// ── Notification count (sidebar badge in terracotta if > 0) ─────────────────
export interface NotifyCount<T> {
/** Return the count of items needing attention (pending requests, stale drafts, etc.). */
count: (items: T[]) => number;
}
// ── Resource ────────────────────────────────────────────────────────────────
export interface Resource<T = Record<string, unknown>> {
/** URL slug — /admin/<key>. */
key: string;
label: string;
pluralLabel: string;
singularLabel: string;
/** Matches a ResourceGroup.key in the registry. */
groupKey: string;
/** Optional one-line subtitle under the page title. */
description?: string;
/** Returns the member-facing URL for an item (for the "View on portal" link). */
publicRoutePattern?: (item: T) => string | null;
list: ListConfig<T>;
/** null marks the resource as read-only (no edit panel, no "+ New" button). */
form: FormConfig | null;
/**
* When form is null but the resource still has actions (e.g. join_requests),
* this defines the read-only fields the review panel renders above the
* action buttons. Returns label/value pairs in display order.
*/
summary?: (item: T) => { label: string; value: string }[];
ops: ResourceOps<T>;
actions?: ResourceAction<T>[];
notifyCount?: NotifyCount<T>;
}
// ── Resource groups (sidebar sections) ──────────────────────────────────────
export interface ResourceGroup {
key: string;
label: string;
// Each resource carries its own item type. Erase the generic at the
// registration boundary so different resources can coexist in one array.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resources: Resource<any>[];
}

View file

@ -0,0 +1,102 @@
/* ---------------------------------------------------------------------------
* Activity resource read-only debug feed.
*
* Activity rows are emitted by side effects elsewhere in the app (voting,
* RSVPs, roadmap-ship transitions, pulse opens). The admin view is a tail
* of the table for monitoring; no create, no edit, no delete.
* ------------------------------------------------------------------------- */
import {
getAllActivityForAdmin,
type ActivityKind,
type ActivityRow,
} from '../../lib/db';
import type { Resource } from '../resource-types';
const KIND_LABEL: Record<ActivityKind, string> = {
voted: 'Voted',
rsvped: 'RSVPed',
booked_office_hours: 'Booked office hours',
roadmap_shipped: 'Roadmap shipped',
pulse_opened: 'Pulse opened',
};
const KIND_PILL_CLASS: Record<ActivityKind, string> = {
voted: 'pill-update',
rsvped: 'pill-published',
booked_office_hours: 'pill-bts',
roadmap_shipped: 'pill-shipping',
pulse_opened: 'pill-pending',
};
const DAY_MS = 24 * 60 * 60 * 1000;
export const activityResource: Resource<ActivityRow> = {
key: 'activity',
label: 'Activity',
pluralLabel: 'Activity',
singularLabel: 'Event',
groupKey: 'system',
description: 'Recent member actions: votes, RSVPs, office-hour bookings, pulse opens, roadmap ships.',
list: {
queryFn: () => getAllActivityForAdmin(200),
columns: [
{
key: 'actor_name',
label: 'Actor',
primary: true,
width: '1.5fr',
render: (item) => ({
title: item.actor_name,
subtitle: item.actor_role,
}),
},
{
key: 'kind',
label: 'Kind',
kind: 'pill',
width: '160px',
pillVariants: Object.fromEntries(
(Object.keys(KIND_LABEL) as ActivityKind[]).map((k) => [
k,
{ label: KIND_LABEL[k], class: KIND_PILL_CLASS[k] },
]),
),
},
{
key: 'subject',
label: 'Subject',
width: '1fr',
render: (item) => ({
title: `${item.subject_type} #${item.subject_id}`,
}),
},
{
key: 'created_at',
label: 'When',
kind: 'relative-date',
width: '120px',
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{
key: 'last_7_days',
label: 'Last 7 days',
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 7 * DAY_MS,
},
{
key: 'last_30_days',
label: 'Last 30 days',
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 30 * DAY_MS,
},
],
defaultSort: { key: 'created_at', direction: 'desc' },
pageSize: 100,
},
// Pure read view — no form, no summary, no ops, no actions.
form: null,
ops: {},
};

View file

@ -0,0 +1,260 @@
/* ---------------------------------------------------------------------------
* Dispatches resource the canonical example of the resource pattern.
*
* The optional pulse sub-form is read out of ctx.formData by the create/update
* handlers (pulse_question / pulse_options[] / pulse_opens_at / pulse_closes_at).
* Status changes ride on the same Save submit: when the form's chosen status
* differs from the current one, publishDispatch / archiveDispatch fire after
* the content save.
* ------------------------------------------------------------------------- */
import {
createDispatch,
updateDispatch,
publishDispatch,
archiveDispatch,
deleteDispatch,
getDispatchById,
getAllDispatchesForAdmin,
getAllUsersPublic,
type DispatchKind,
type DispatchPollInput,
type DispatchStatus,
type DispatchWithAuthor,
} from '../../lib/db';
import { dispatchSlug } from '../../lib/format';
import type { FieldContext, Resource } from '../resource-types';
// ── Helpers ─────────────────────────────────────────────────────────────────
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
function toSqliteDatetime(s: string): string {
if (!s) return '';
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
}
function nowSqlite(): string {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
function plusDaysSqlite(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
}
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
const question = String(formData.get('pulse_question') ?? '').trim();
if (!question) return null;
const options = formData
.getAll('pulse_options')
.map((v) => String(v).trim())
.filter(Boolean)
.slice(0, 4);
if (options.length < 2) return null;
const opens_at =
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
const closes_at =
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
return { question, options, opens_at, closes_at };
}
// ── Resource ────────────────────────────────────────────────────────────────
export const dispatchesResource: Resource<DispatchWithAuthor> = {
key: 'dispatches',
label: 'Dispatches',
pluralLabel: 'Dispatches',
singularLabel: 'Dispatch',
groupKey: 'publishing',
description: 'Updates, decisions, notes — the public record of pilot progress.',
publicRoutePattern: (item) => `/dispatches/${dispatchSlug(item)}`,
list: {
queryFn: () => getAllDispatchesForAdmin(),
columns: [
{
key: 'title',
label: 'Title',
primary: true,
width: '2fr',
render: (item) => ({
title: item.title,
subtitle: item.author_name,
}),
},
{
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: 25,
},
// Drafts in the sidebar light up terracotta until they're published.
notifyCount: {
count: (items) => items.filter((i) => i.status === 'draft').length,
},
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: 'author_id',
label: 'Author',
kind: 'select-async',
required: true,
loadOptions: () =>
getAllUsersPublic()
.filter((u) => u.role === 'fenja')
.map((u) => ({ value: u.id, label: u.name })),
defaultValue: (ctx: FieldContext) => ctx.actingUserId,
},
{
key: 'excerpt',
label: 'Excerpt',
kind: 'textarea',
rows: 4,
helperText:
'Two to four sentences. The first sentence becomes the lead paragraph on the dispatch banner. Leave blank to fall back to the first ~200 chars of body.',
},
{
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, kept here)' },
],
defaultValue: 'draft',
helperText:
'Switching from draft to published is the same as clicking the Publish action — the dispatch becomes visible to all members.',
},
],
embeds: [{ key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }],
},
ops: {
getById: (id) => getDispatchById(id),
create: (data, ctx) => {
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
const status = (data.status as DispatchStatus) ?? 'draft';
return createDispatch({
title: String(data.title),
body: String(data.body),
excerpt: ((data.excerpt as string) ?? '').trim() || null,
kind: data.kind as DispatchKind,
author_id: Number(data.author_id),
status,
poll,
});
},
update: (id, data, ctx) => {
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
const current = getDispatchById(id);
if (!current) throw new Error(`Dispatch ${id} not found`);
updateDispatch(id, {
title: String(data.title),
body: String(data.body),
excerpt: ((data.excerpt as string) ?? '').trim() || null,
kind: data.kind as DispatchKind,
author_id: Number(data.author_id),
poll,
// Only flag pollExplicit when a question was actually submitted.
// Empty pulse fields leave the existing pulse alone.
pollExplicit: poll !== null,
});
// Status transitions ride on the save submit.
const desiredStatus = (data.status as DispatchStatus) ?? current.status;
if (desiredStatus !== current.status) {
if (desiredStatus === 'published') publishDispatch(id);
else if (desiredStatus === 'archived') archiveDispatch(id);
// 'draft' from another state is a no-op — there's no "unpublish".
}
},
delete: (id) => deleteDispatch(id),
},
actions: [
{
key: 'publish',
label: 'Publish now',
visibleWhen: (item) => item.status === 'draft',
confirmText: 'Publish this dispatch to all members?',
handler: (id) => {
publishDispatch(id);
},
},
{
key: 'archive',
label: 'Archive',
visibleWhen: (item) => item.status === 'published',
destructive: true,
confirmText: 'Archive this dispatch? It will be hidden from members.',
handler: (id) => {
archiveDispatch(id);
},
},
],
};

View file

@ -0,0 +1,250 @@
/* ---------------------------------------------------------------------------
* Events resource.
*
* Slug auto-generates from title on create when blank; on edit it's a regular
* editable text field (changing it breaks any external links admin's call).
* ------------------------------------------------------------------------- */
import {
createEvent,
updateEvent,
deleteEvent,
getEventById,
getEventBySlug,
getAllEvents,
getEventRsvpCount,
type Event,
type EventKind,
} from '../../lib/db';
import { eventKindLabel } from '../../lib/format';
import type { Resource } from '../resource-types';
function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function uniqueEventSlug(base: string): string {
let slug = base || 'event';
let n = 1;
while (getEventBySlug(slug)) {
n += 1;
slug = `${base}-${n}`;
}
return slug;
}
function toSqliteDatetime(s: string): string {
if (!s) return '';
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
}
export const eventsResource: Resource<Event> = {
key: 'events',
label: 'Events',
pluralLabel: 'Events',
singularLabel: 'Event',
groupKey: 'publishing',
description: 'Gatherings, dinners, virtual sessions — anything that shows up at /events.',
publicRoutePattern: (item) => `/events/${item.slug}`,
list: {
queryFn: () => getAllEvents(),
columns: [
{
key: 'title',
label: 'Title',
primary: true,
width: '2fr',
render: (item) => ({
title: item.title,
subtitle: item.location,
}),
},
{
key: 'kind',
label: 'Kind',
kind: 'pill',
width: '140px',
pillVariants: {
dinner: { label: 'Dinner', class: 'pill-decision' },
office_hours: { label: 'Studio hours', class: 'pill-update' },
summit: { label: 'Summit', class: 'pill-note' },
virtual: { label: 'Virtual', class: 'pill-bts' },
working_session: { label: 'Working session', class: 'pill-considering' },
},
},
{
key: 'starts_at',
label: 'Starts',
kind: 'relative-date',
width: '110px',
emptyFallback: '—',
},
{
key: 'capacity',
label: 'Capacity',
kind: 'number',
width: '90px',
},
],
filters: [
{
key: 'all',
label: 'All',
predicate: () => true,
isDefault: true,
},
{
key: 'upcoming',
label: 'Upcoming',
predicate: (i) => new Date(i.starts_at).getTime() >= Date.now(),
},
{
key: 'past',
label: 'Past',
predicate: (i) => new Date(i.starts_at).getTime() < Date.now(),
},
],
search: {
placeholder: 'Search by title or location…',
fields: ['title', 'location', 'description'],
},
defaultSort: { key: 'starts_at', direction: 'desc' },
pageSize: 25,
},
form: {
fields: [
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
{
key: 'slug',
label: 'Slug',
kind: 'text',
maxLength: 80,
helperText: 'URL path under /events/. Leave blank on create to auto-generate from the title.',
},
{
key: 'kind',
label: 'Kind',
kind: 'select',
required: true,
options: (['dinner', 'office_hours', 'summit', 'virtual', 'working_session'] as EventKind[]).map(
(k) => ({ value: k, label: eventKindLabel(k) }),
),
defaultValue: 'dinner',
},
{
key: 'description',
label: 'Description',
kind: 'textarea',
rows: 5,
required: true,
},
{
key: 'location',
label: 'Location',
kind: 'text',
required: true,
maxLength: 200,
helperText: 'Address, room, or video link.',
},
{ key: 'starts_at', label: 'Starts at', kind: 'datetime', required: true },
{ key: 'ends_at', label: 'Ends at', kind: 'datetime' },
{
key: 'capacity',
label: 'Capacity',
kind: 'number',
min: 0,
max: 999,
helperText: 'Leave blank for uncapped.',
},
{
key: 'audience',
label: 'Audience',
kind: 'text',
maxLength: 200,
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
},
{
key: 'duration_label',
label: 'Duration label',
kind: 'text',
maxLength: 40,
helperText: 'Optional display label like "90 min" or "Half day".',
},
{
key: 'action_label',
label: 'Action button label',
kind: 'text',
maxLength: 40,
helperText: 'Override the default per-kind CTA (e.g. "Reserve your table").',
},
{
key: 'photo_url',
label: 'Photo URL',
kind: 'text',
maxLength: 400,
helperText: 'Optional hero image for the event detail page.',
},
{
key: 'notes_url',
label: 'Notes URL',
kind: 'text',
maxLength: 400,
helperText: 'Optional link to event notes published after the gathering.',
},
],
},
ops: {
getById: (id) => getEventById(id),
create: (data, ctx) => {
const rawSlug = ((data.slug as string) ?? '').trim();
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
return createEvent({
slug,
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience as string) ?? '').trim() || null,
duration_label: ((data.duration_label as string) ?? '').trim() || null,
action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null,
created_by: ctx.user.id,
});
},
update: (id, data) => {
updateEvent(id, {
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience as string) ?? '').trim() || null,
duration_label: ((data.duration_label as string) ?? '').trim() || null,
action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null,
});
},
delete: (id) => deleteEvent(id),
},
};
// Internal use, not exported on the resource — used by future row subtitles
// if we want RSVP counts in the list view. Left here as a marker.
export { getEventRsvpCount };

View file

@ -0,0 +1,33 @@
/* ---------------------------------------------------------------------------
* Resource registry single source of truth for sidebar navigation.
*
* Groups are populated incrementally across steps 810 of the Backstage
* rebuild. The display order inside each group matches sidebar order.
* ------------------------------------------------------------------------- */
import type { ResourceGroup } from '../resource-types';
import { dispatchesResource } from './dispatches';
import { roadmapResource } from './roadmap';
import { eventsResource } from './events';
import { usersResource } from './users';
import { invitationsResource } from './invitations';
import { joinRequestsResource } from './join-requests';
import { activityResource } from './activity';
export const groups: ResourceGroup[] = [
{
key: 'publishing',
label: 'Publishing',
resources: [dispatchesResource, roadmapResource, eventsResource],
},
{
key: 'council',
label: 'The council',
resources: [usersResource, invitationsResource, joinRequestsResource],
},
{
key: 'system',
label: 'System',
resources: [activityResource],
},
];

View file

@ -0,0 +1,192 @@
/* ---------------------------------------------------------------------------
* Invitations resource.
*
* Create surfaces the magic link via ctx.result.invite-link the route
* handler propagates it as ?invite_url=... and the edit panel renders a
* copy-to-clipboard block. The token itself is never stored only its
* hash so the link is shown exactly once.
*
* "Revoke" is implemented as an action (sets expires_at = now()), not as
* ops.delete, because the row stays in the table for audit history.
* ------------------------------------------------------------------------- */
import {
createInvite,
getAllInvites,
getInviteById,
revokeInvite,
type Invite,
type Role,
} from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { relativeTime } from '../../lib/format';
import { fmtDateTime } from '../../lib/markdown';
import type { Resource } from '../resource-types';
type InviteRow = Invite & { creator_name: string | null };
function deriveStatus(item: InviteRow): 'pending' | 'accepted' | 'expired' {
if (item.used_at) return 'accepted';
if (new Date(item.expires_at).getTime() < Date.now()) return 'expired';
return 'pending';
}
export const invitationsResource: Resource<InviteRow> = {
key: 'invitations',
label: 'Invitations',
pluralLabel: 'Invitations',
singularLabel: 'Invitation',
groupKey: 'council',
description: 'Magic links sent to new pilots and council members. Tokens are shown once on create.',
list: {
queryFn: () => getAllInvites(),
columns: [
{
key: 'email',
label: 'Email',
primary: true,
width: '2fr',
render: (item) => ({
title: item.email,
subtitle: `${item.name} · ${item.organisation || '—'}`,
}),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '110px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'creator_name',
label: 'Invited by',
width: '140px',
render: (item) => ({ title: item.creator_name || '—' }),
},
{
key: 'created_at',
label: 'Created',
kind: 'relative-date',
width: '110px',
},
{
key: 'status',
label: 'Status',
kind: 'pill',
width: '110px',
value: (item) => deriveStatus(item),
pillVariants: {
pending: { label: 'Pending', class: 'pill-pending' },
accepted: { label: 'Accepted', class: 'pill-accepted' },
expired: { label: 'Expired', class: 'pill-expired' },
},
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'pending', label: 'Pending', predicate: (i) => deriveStatus(i) === 'pending' },
{ key: 'accepted', label: 'Accepted', predicate: (i) => deriveStatus(i) === 'accepted' },
{ key: 'expired', label: 'Expired', predicate: (i) => deriveStatus(i) === 'expired' },
],
search: {
placeholder: 'Search by email or name…',
fields: ['email', 'name', 'organisation'],
},
defaultSort: { key: 'created_at', direction: 'desc' },
pageSize: 50,
},
form: {
fields: [
{
key: 'email',
label: 'Email',
kind: 'text',
required: true,
maxLength: 240,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
helperText: 'Where the magic link will land. Cannot be changed later.',
},
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
{ key: 'organisation', label: 'Organisation', kind: 'text', maxLength: 200 },
{
key: 'role',
label: 'Role',
kind: 'select',
required: true,
options: [
{ value: 'pilot', label: 'Pilot' },
{ value: 'cab', label: 'Council' },
],
defaultValue: 'pilot',
helperText: 'Council invites allocate a member number on signup.',
},
],
},
// Existing invites are immutable — the panel renders the summary +
// Revoke action via the review-mode pathway. Create still uses the form.
summary: (item) => {
const status = deriveStatus(item);
return [
{ label: 'Email', value: item.email },
{ label: 'Name', value: item.name },
{ label: 'Organisation', value: item.organisation || '—' },
{
label: 'Role',
value: item.role === 'cab' ? 'Council' : item.role === 'pilot' ? 'Pilot' : 'Fenja team',
},
{ label: 'Status', value: status === 'pending' ? 'Pending' : status === 'accepted' ? 'Accepted' : 'Expired' },
{ label: 'Invited by', value: item.creator_name ?? '—' },
{ label: 'Created', value: relativeTime(item.created_at) },
{ label: 'Expires', value: fmtDateTime(item.expires_at) },
];
},
ops: {
getById: (id) => getInviteById(id),
create: (data, ctx) => {
const { token, tokenHash } = generateInviteToken();
const id = createInvite({
token_hash: tokenHash,
email: String(data.email).trim().toLowerCase(),
name: String(data.name).trim(),
organisation: ((data.organisation as string) ?? '').trim(),
role: data.role as Role,
expires_at: inviteExpiresAt(),
created_by_user_id: ctx.user.id,
});
// Surface the one-shot magic link via the result mechanism — the route
// handler propagates it as ?invite_url= and the panel renders a copy
// block on the next page load.
const origin = process.env.PUBLIC_ORIGIN ?? '';
ctx.result = {
kind: 'invite-link',
url: `${origin}/invite?t=${token}`,
};
return id;
},
},
actions: [
{
key: 'revoke',
label: 'Revoke',
visibleWhen: (item) => deriveStatus(item) === 'pending',
destructive: true,
confirmText:
'Revoke this invitation? The magic link will stop working immediately.',
handler: (id) => {
revokeInvite(id);
},
},
],
};

View file

@ -0,0 +1,110 @@
/* ---------------------------------------------------------------------------
* Join requests resource read-only review surface.
*
* The existing data model: a pilot user (already in the system) requests
* promotion to council. The request joins to users for name/email/org;
* there's no separate "stranger sign-up" model. As a result, the approval
* flow upgrades the existing user's role rather than minting a fresh invite.
*
* Deviation from the original delta: no approve_as_pilot action (the
* requester is already a pilot) and no invite-link result (the user already
* exists). Stranger sign-ups would require a schema change to the
* join_requests table left for a future follow-up.
* ------------------------------------------------------------------------- */
import {
getAllJoinRequests,
getJoinRequestById,
deleteJoinRequest,
updateUserRole,
type JoinRequest,
} from '../../lib/db';
import { relativeTime } from '../../lib/format';
import type { Resource } from '../resource-types';
export const joinRequestsResource: Resource<JoinRequest> = {
key: 'join_requests',
label: 'Join requests',
pluralLabel: 'Join requests',
singularLabel: 'Join request',
groupKey: 'council',
description: 'Pilots asking to be upgraded to council. Approve to grant access, decline to dismiss.',
list: {
queryFn: () => getAllJoinRequests(),
columns: [
{
key: 'user_name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.user_name, subtitle: item.user_email }),
},
{
key: 'user_organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.user_organisation || '—' }),
},
{
key: 'created_at',
label: 'Requested',
kind: 'relative-date',
width: '120px',
},
],
search: {
placeholder: 'Search by name, email, organisation…',
fields: ['user_name', 'user_email', 'user_organisation'],
},
defaultSort: { key: 'created_at', direction: 'desc' },
pageSize: 50,
},
// Read-only: no edit form, no create flow.
form: null,
// Notify count = total pending requests (everything in the table is
// pending under the current model — there's no status column yet).
notifyCount: {
count: (items) => items.length,
},
// Review panel summary — shown when an item is clicked.
summary: (item) => [
{ label: 'Name', value: item.user_name },
{ label: 'Email', value: item.user_email },
{ label: 'Organisation', value: item.user_organisation || '—' },
{ label: 'Requested', value: relativeTime(item.created_at) },
],
ops: {
getById: (id) => getJoinRequestById(id),
// No delete in ops — declining is an action below, so the destructive
// intent is named explicitly in the panel.
},
actions: [
{
key: 'approve_as_cab',
label: 'Approve as council',
confirmText:
'Promote this pilot to council? They will gain access to council-only surfaces.',
handler: (id) => {
const req = getJoinRequestById(id);
if (!req) return;
updateUserRole(req.user_id, 'cab');
deleteJoinRequest(id);
},
},
{
key: 'decline',
label: 'Decline',
destructive: true,
confirmText: 'Decline this request? It will be removed from the queue.',
handler: (id) => {
deleteJoinRequest(id);
},
},
],
};

View file

@ -0,0 +1,185 @@
/* ---------------------------------------------------------------------------
* Roadmap items resource.
*
* Attributed members come in via a multi-select-async loading all users; the
* update handler calls setRoadmapAttributions() after the basic update so the
* pivot table reflects the current selection.
* ------------------------------------------------------------------------- */
import {
createRoadmapItem,
updateRoadmapItem,
deleteRoadmapItem,
getRoadmapItem,
getAllRoadmapItems,
getAllUsersPublic,
setRoadmapAttributions,
type RoadmapItemWithAttribution,
type RoadmapStatus,
} from '../../lib/db';
import type { Resource } from '../resource-types';
export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
key: 'roadmap',
label: 'Roadmap',
pluralLabel: 'Roadmap items',
singularLabel: 'Roadmap item',
groupKey: 'publishing',
description: 'The route members see at /roadmap — what is shipping, in beta, exploring, or considered.',
list: {
queryFn: () => getAllRoadmapItems(),
columns: [
{
key: 'title',
label: 'Title',
primary: true,
width: '2fr',
render: (item) => ({
title: item.title,
subtitle: item.description.slice(0, 80) + (item.description.length > 80 ? '…' : ''),
}),
},
{
key: 'status',
label: 'Status',
kind: 'pill',
width: '120px',
pillVariants: {
shipping: { label: 'Shipping', class: 'pill-shipping' },
in_beta: { label: 'In beta', class: 'pill-in-beta' },
exploring: { label: 'Exploring', class: 'pill-exploring' },
considering: { label: 'Considering', class: 'pill-considering' },
},
},
{
key: 'target',
label: 'Target',
width: '140px',
render: (item) => ({ title: item.target ?? '—' }),
},
{
key: 'display_order',
label: 'Order',
kind: 'number',
width: '70px',
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
],
search: {
placeholder: 'Search by title or description…',
fields: ['title', 'description'],
},
defaultSort: { key: 'display_order', direction: 'asc' },
pageSize: 50,
},
form: {
fields: [
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
{
key: 'description',
label: 'Description',
kind: 'textarea',
rows: 4,
required: true,
helperText: 'Shown on hover in the /roadmap route. Keep it to a sentence or two.',
},
{
key: 'status',
label: 'Status',
kind: 'select',
required: true,
options: [
{ value: 'shipping', label: 'Shipping' },
{ value: 'in_beta', label: 'In beta' },
{ value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' },
],
defaultValue: 'exploring',
},
{
key: 'target',
label: 'Target',
kind: 'text',
maxLength: 80,
helperText: 'Free-form quarter or date, e.g. "Q2 2026" or "Late May".',
},
{
key: 'display_order',
label: 'Display order',
kind: 'number',
min: 0,
max: 999,
defaultValue: 0,
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
},
{
key: 'metadata_text',
label: 'Hover metadata',
kind: 'text',
maxLength: 120,
helperText: 'Short narrative cue shown on hover, e.g. "shipped 3 days ago".',
},
{
key: 'attributed_members',
label: 'Attributed members',
kind: 'multi-select-async',
loadOptions: () =>
getAllUsersPublic().map((u) => ({ value: u.id, label: u.name })),
helperText: 'Members credited for this item. Surfaces on their public profile and on the milestone card.',
},
],
},
ops: {
getById: (id) => {
const item = getRoadmapItem(id);
if (!item) return null;
// Expose attributed user-ids under the key the multi-select expects.
return {
...item,
attributed_members: item.attributed.map((u) => u.id),
} as unknown as RoadmapItemWithAttribution;
},
create: (data) => {
const id = createRoadmapItem({
title: String(data.title),
description: String(data.description),
status: data.status as RoadmapStatus,
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
});
const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
: [];
if (userIds.length > 0) setRoadmapAttributions(id, userIds);
return id;
},
update: (id, data) => {
updateRoadmapItem(id, {
title: String(data.title),
description: String(data.description),
status: data.status as RoadmapStatus,
target: ((data.target as string) ?? '').trim() || null,
display_order: Number(data.display_order ?? 0),
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
});
const userIds = Array.isArray(data.attributed_members)
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
: [];
setRoadmapAttributions(id, userIds);
},
delete: (id) => deleteRoadmapItem(id),
},
};

View file

@ -0,0 +1,324 @@
/* ---------------------------------------------------------------------------
* People (users) resource replaces the old Participants tab.
*
* Single resource for every user, regardless of role. The filter chips swap
* the visible columns (council shows member_number + focus_tags; pilots/team
* show role + last_seen_at). The edit panel's CAB-specific fields render
* only when the user is in role=cab.
*
* Creation is intentionally absent users come in through invites, not
* direct admin creation. The "+ New" button is suppressed automatically
* because ops.create is undefined.
* ------------------------------------------------------------------------- */
import {
getAllUsersPublic,
getUserPublicById,
updateUserAdminFields,
updateUserProfile,
updateUserRole,
deactivateUser,
type Role,
type UserPublic,
} from '../../lib/db';
import { parseFocusTags, readFocusTags } from '../../lib/format';
import type { Resource } from '../resource-types';
const ROLE_LABEL: Record<Role, string> = {
pilot: 'Pilot',
cab: 'Council',
fenja: 'Fenja team',
};
const ROLE_PILL_CLASS: Record<Role, string> = {
pilot: 'pill-pilot',
cab: 'pill-cab',
fenja: 'pill-fenja',
};
export const usersResource: Resource<UserPublic> = {
key: 'users',
label: 'People',
pluralLabel: 'People',
singularLabel: 'Person',
groupKey: 'council',
description: 'Everyone with an account on the portal — pilots, council, and team.',
publicRoutePattern: (item) => (item.slug ? `/members/${item.slug}` : null),
list: {
queryFn: () => getAllUsersPublic(),
// Default columns shown under "Council" (the default filter): the CAB-
// specific identity columns.
columns: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({
title: item.name,
subtitle: item.email,
}),
},
{
key: 'member_number',
label: 'Member #',
kind: 'number',
width: '100px',
value: (item) => item.member_number,
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'focus_tags',
label: 'Focus',
kind: 'tag-list',
width: '1.5fr',
value: (item) => readFocusTags(item.focus_tags),
},
],
// Pilots / Team / All show role + organisation + last seen instead.
columnsByFilter: {
pilots: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
team: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
all: [
{
key: 'name',
label: 'Name',
primary: true,
width: '2fr',
render: (item) => ({ title: item.name, subtitle: item.email }),
},
{
key: 'role',
label: 'Role',
kind: 'pill',
width: '120px',
pillVariants: {
pilot: { label: 'Pilot', class: 'pill-pilot' },
cab: { label: 'Council', class: 'pill-cab' },
fenja: { label: 'Fenja team', class: 'pill-fenja' },
},
},
{
key: 'organisation',
label: 'Organisation',
width: '1.5fr',
render: (item) => ({ title: item.organisation || '—' }),
},
{
key: 'last_seen_at',
label: 'Last seen',
kind: 'relative-date',
width: '120px',
emptyFallback: 'never',
},
],
},
filters: [
{ key: 'council', label: 'Council', predicate: (i) => i.role === 'cab', isDefault: true },
{ key: 'pilots', label: 'Pilots', predicate: (i) => i.role === 'pilot' },
{ key: 'team', label: 'Team', predicate: (i) => i.role === 'fenja' },
{ key: 'all', label: 'All', predicate: () => true },
],
search: {
placeholder: 'Search by name, email, organisation…',
fields: ['name', 'email', 'organisation'],
},
defaultSort: { key: 'name', direction: 'asc' },
pageSize: 50,
},
form: {
fields: [
// ── Always visible ───────────────────────────────────────────────
{
key: 'role',
label: 'Role',
kind: 'select',
required: true,
options: [
{ value: 'pilot', label: 'Pilot' },
{ value: 'cab', label: 'Council' },
{ value: 'fenja', label: 'Fenja team' },
],
helperText:
'Changing the role has real access consequences. Setting to Council also allocates a member number.',
},
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
{ key: 'email', label: 'Email', kind: 'text', readOnly: true },
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
helperText: 'Set at sign-up; editing is not yet supported.' },
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
// ── CAB-only ────────────────────────────────────────────────────
{
key: 'title',
label: 'Title',
kind: 'text',
maxLength: 120,
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'pull_quote',
label: 'Pull quote',
kind: 'textarea',
rows: 3,
maxLength: 240,
helperText: 'Shown on the member profile page. Two sentences max.',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'focus_tags_text',
label: 'Focus tags',
kind: 'text',
maxLength: 80,
helperText:
'Comma-separated. Up to 3 tags, 24 chars each. Normalised on save.',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
},
{
key: 'cab_joined_date',
label: 'Council joined',
kind: 'readonly',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
render: (value) => (value ? String(value) : '—'),
},
{
key: 'member_number',
label: 'Member number',
kind: 'readonly',
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
render: (value) => (value ? `#${value}` : 'pending'),
},
],
},
ops: {
getById: (id) => {
const u = getUserPublicById(id);
if (!u) return null;
// Surface focus_tags as plaintext for the editor.
return {
...u,
focus_tags_text: readFocusTags(u.focus_tags).join(', '),
} as unknown as UserPublic;
},
// No ops.create — users come in via invites.
update: (id, data) => {
const current = getUserPublicById(id);
if (!current) throw new Error(`User ${id} not found`);
// Profile fields (name + bio).
const newName = String(data.name ?? current.name);
const newBio = String(data.bio ?? current.bio ?? '');
updateUserProfile(id, newName, newBio);
// Role transition — runs after profile update so member_number can be
// allocated against an up-to-date user row.
const newRole = data.role as Role;
if (newRole && newRole !== current.role) {
updateUserRole(id, newRole);
}
// CAB-specific admin fields. Only applied when the user is CAB after
// the role update; otherwise the form fields aren't visible.
const isCab = (newRole ?? current.role) === 'cab';
if (isCab) {
const tagsRaw = String(data.focus_tags_text ?? '');
updateUserAdminFields(id, {
title: ((data.title as string) ?? '').trim() || null,
pull_quote: ((data.pull_quote as string) ?? '').trim() || null,
focus_tags: parseFocusTags(tagsRaw),
});
}
},
delete: (id) => deactivateUser(id),
},
notifyCount: {
// CAB members without focus tags read as half-finished profiles —
// surface them as something to attend to.
count: (items) =>
items.filter(
(u) => u.role === 'cab' && readFocusTags(u.focus_tags).length === 0,
).length,
},
};
// Keep the role label map exported for any callers that want display copy.
export { ROLE_LABEL, ROLE_PILL_CLASS };

116
src/admin/validate.ts Normal file
View file

@ -0,0 +1,116 @@
/* ---------------------------------------------------------------------------
* Form validation derived from a Resource's field definitions.
*
* Returns a map of field.key error message. Empty object = valid.
* Server-side authoritative; the client may show hints, but every POST
* must call this before writing to the DB.
* ------------------------------------------------------------------------- */
import type { Field, FieldContext, Resource } from './resource-types';
export type ValidationErrors = Record<string, string>;
export interface ValidateArgs {
resource: Resource;
data: Record<string, unknown>;
/** Existing item being edited, or null on create. */
item: Record<string, unknown> | null;
actingUserId: number;
}
export function validateForResource(args: ValidateArgs): ValidationErrors {
const { resource, data, item, actingUserId } = args;
const errors: ValidationErrors = {};
if (!resource.form) return errors;
const ctx: FieldContext = { formValues: data, item, actingUserId };
for (const field of resource.form.fields) {
if (field.readOnly) continue;
if (field.visibleWhen && !field.visibleWhen(ctx)) continue;
const value = data[field.key];
const error = validateField(field, value);
if (error) errors[field.key] = error;
}
return errors;
}
function validateField(field: Field, value: unknown): string | null {
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
if (field.required && isEmpty) {
return `${field.label} is required`;
}
if (isEmpty) return null;
switch (field.kind) {
case 'text':
case 'textarea':
case 'markdown': {
if (typeof value !== 'string') return `${field.label} must be text`;
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
return `${field.label} must be ${field.maxLength} characters or fewer`;
}
if (field.kind === 'text' && field.pattern && !field.pattern.test(value)) {
return `${field.label} is not in the expected format`;
}
return null;
}
case 'select':
case 'select-async': {
// Empty already handled. Anything else is accepted at the validate layer;
// option membership is enforced by the route handler against a fresh option list.
return null;
}
case 'multi-select-async': {
if (!Array.isArray(value)) return `${field.label} must be a list`;
return null;
}
case 'multi-text': {
if (!Array.isArray(value)) return `${field.label} must be a list`;
const filled = value.filter(
(v) => typeof v === 'string' && v.trim() !== '',
);
if (field.minItems !== undefined && filled.length < field.minItems) {
return `${field.label} requires at least ${field.minItems} entries`;
}
if (field.maxItems !== undefined && filled.length > field.maxItems) {
return `${field.label} allows at most ${field.maxItems} entries`;
}
return null;
}
case 'number': {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return `${field.label} must be a number`;
if (field.min !== undefined && n < field.min) {
return `${field.label} must be at least ${field.min}`;
}
if (field.max !== undefined && n > field.max) {
return `${field.label} must be no more than ${field.max}`;
}
return null;
}
case 'date':
case 'datetime': {
if (typeof value !== 'string') return `${field.label} must be a date`;
const t = Date.parse(value);
if (Number.isNaN(t)) return `${field.label} is not a valid date`;
return null;
}
case 'readonly':
return null;
}
}

View file

@ -1,44 +0,0 @@
---
import type { ActivityRow } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
interface Props {
rows: ActivityRow[];
}
const { rows } = Astro.props;
---
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">Recent activity</h2>
<p class="body-sm section-note">
The raw activity feed — what powers the ticker on /pulse. Read-only debug view.
Showing up to 200 most-recent events; the ticker takes the last 12 within 7 days.
</p>
{rows.length === 0 ? (
<p class="body-sm empty-msg">No activity recorded yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">When</th>
<th class="label-sm">Actor</th>
<th class="label-sm">Kind</th>
<th class="label-sm">Subject</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr>
<td class="body-sm muted">{fmtDateTime(r.created_at)}</td>
<td class="body-sm">{r.actor_name} <span class="muted">({r.actor_role})</span></td>
<td class="body-sm" style="text-transform:lowercase">{r.kind}</td>
<td class="body-sm muted">{r.subject_type} #{r.subject_id}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>

View file

@ -1,268 +0,0 @@
---
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
editingPoll: PulseRow | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props;
function toInputValue(sql: string | null | undefined): string {
if (!sql) return '';
return sql.replace(' ', 'T').slice(0, 16);
}
const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : [];
while (pollOptionsForForm.length < 4) pollOptionsForForm.push('');
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
published: 'Published',
archived: 'Archived',
};
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
const defaultAuthorId = editing?.author_id ?? currentUserId;
---
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
<div class="field">
<label for="d-title" class="label-sm field-label">Title</label>
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="form-grid">
<div class="field">
<label for="d-kind" class="label-sm field-label">Kind</label>
<select id="d-kind" name="kind" class="select body-md" required>
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
))}
</select>
</div>
<div class="field">
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
<select id="d-author" name="author_id" class="select body-md" required>
{fenjaUsers.map(u => (
<option value={u.id} selected={u.id === defaultAuthorId}>
{u.name}{u.title ? ` — ${u.title}` : ''}
</option>
))}
</select>
</div>
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label>
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea>
<span class="body-sm muted">Write 24 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty.</span>
</div>
<div class="field">
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
</div>
{!editing && (
<div class="form-grid">
<div class="field">
<label for="d-status" class="label-sm field-label">Status on save</label>
<select id="d-status" name="status" class="select body-md">
<option value="draft" selected>Draft (hidden from members)</option>
<option value="published">Published (stamps published_at)</option>
</select>
</div>
</div>
)}
<!-- ── Attached poll (optional) ────────────────────────────── -->
<fieldset class="poll-fieldset">
<legend class="label-sm field-label">Attach a poll (optional)</legend>
<input type="hidden" name="poll_explicit" value="1" />
<p class="body-sm muted poll-help">
Fill in a question and at least two options to attach a poll. Leave them all blank
to {editingPoll ? 'detach the existing poll' : 'skip'}.
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
</p>
<div class="field">
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
<input
type="text"
id="d-poll-question"
name="poll_question"
class="input body-md"
value={editingPoll?.question ?? ''}
placeholder={editing ? editing.title : 'A question for the council'}
/>
</div>
<div class="poll-options-grid">
{pollOptionsForForm.map((val, i) => (
<input
type="text"
name={`poll_option_${i}`}
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
class="input body-md"
value={val}
/>
))}
</div>
<div class="form-grid">
<div class="field">
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
<input
type="datetime-local"
id="d-poll-opens"
name="poll_opens_at"
class="input body-md"
value={toInputValue(editingPoll?.opens_at)}
/>
</div>
<div class="field">
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
<input
type="datetime-local"
id="d-poll-closes"
name="poll_closes_at"
class="input body-md"
value={toInputValue(editingPoll?.closes_at)}
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All dispatches</h2>
{dispatches.length === 0 ? (
<p class="body-sm empty-msg">No dispatches yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">Author</th>
<th class="label-sm">Status</th>
<th class="label-sm">Published</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{dispatches.map(d => (
<tr>
<td class="body-sm">{d.title}</td>
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
<td class="body-sm">{d.author_name}</td>
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
{d.status === 'draft' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="publish_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Publish</button>
</form>
)}
{d.status === 'published' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="archive_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Archive</button>
</form>
)}
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<style>
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.poll-fieldset {
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
}
.poll-fieldset legend {
padding: 0 var(--space-2);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.poll-help { color: var(--on-surface-muted); margin: 0; }
.poll-existing-flag { color: var(--pigment-terracotta); }
.poll-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.muted { color: var(--on-surface-muted); }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);
border-radius: var(--radius-full);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
.status-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -1,218 +0,0 @@
---
import type { Event } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
interface Props {
events: Event[];
editing: Event | null;
viewing: Event | null;
viewingRsvps: { going: number; interested: number; declined: number } | null;
}
const { events, editing, viewing, viewingRsvps } = Astro.props;
const KIND_LABEL = {
dinner: 'Dinner',
office_hours: 'Studio hours',
summit: 'Summit',
virtual: 'Virtual',
working_session: 'Working session',
} as const;
function toInputValue(sql: string | null | undefined): string {
if (!sql) return '';
return sql.replace(' ', 'T').slice(0, 16);
}
const formAction = editing ? 'update_event' : 'create_event';
---
<div class="tab-content">
{viewing && viewingRsvps ? (
<section class="section">
<a href="/admin?tab=events" class="back-link label-sm">← Back to events</a>
<h2 class="label-sm section-heading">RSVPs — {viewing.title}</h2>
<p class="body-sm muted">{fmtDateTime(viewing.starts_at)} · {viewing.location}</p>
<dl class="rsvp-summary">
<div><dt class="label-sm">Going</dt><dd class="rsvp-count">{viewingRsvps.going}</dd></div>
<div><dt class="label-sm">Interested</dt><dd class="rsvp-count">{viewingRsvps.interested}</dd></div>
<div><dt class="label-sm">Declined</dt><dd class="rsvp-count">{viewingRsvps.declined}</dd></div>
</dl>
</section>
) : (
<>
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit event' : 'New event'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="event_id" value={editing.id} />}
<div class="form-grid">
<div class="field">
<label for="title" class="label-sm field-label">Title</label>
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="field">
<label for="slug" class="label-sm field-label">Slug (URL)</label>
<input type="text" id="slug" name="slug" class="input body-md" required value={editing?.slug ?? ''} readonly={!!editing} />
</div>
<div class="field">
<label for="kind" class="label-sm field-label">Kind</label>
<select id="kind" name="kind" class="select body-md" required>
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
</select>
</div>
<div class="field">
<label for="location" class="label-sm field-label">Location</label>
<input type="text" id="location" name="location" class="input body-md" value={editing?.location ?? ''} />
</div>
<div class="field">
<label for="starts_at" class="label-sm field-label">Starts at (UTC)</label>
<input type="datetime-local" id="starts_at" name="starts_at" class="input body-md" required value={toInputValue(editing?.starts_at)} />
</div>
<div class="field">
<label for="ends_at" class="label-sm field-label">Ends at (optional)</label>
<input type="datetime-local" id="ends_at" name="ends_at" class="input body-md" value={toInputValue(editing?.ends_at)} />
</div>
<div class="field">
<label for="capacity" class="label-sm field-label">Capacity (optional)</label>
<input type="number" id="capacity" name="capacity" class="input body-md" value={editing?.capacity ?? ''} />
</div>
<div class="field">
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
</div>
<div class="field">
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
</div>
<div class="field">
<label for="duration_label" class="label-sm field-label">Duration label</label>
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
</div>
<div class="field">
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
</div>
<div class="field">
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
</div>
</div>
<div class="field">
<label for="description" class="label-sm field-label">Description</label>
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create event'}</button>
{editing && <a href="/admin?tab=events" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All events</h2>
{events.length === 0 ? (
<p class="body-sm empty-msg">No events yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">When</th>
<th class="label-sm">Location</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{events.map(ev => (
<tr>
<td class="body-sm">{ev.title}</td>
<td class="body-sm muted">{KIND_LABEL[ev.kind]}</td>
<td class="body-sm muted">{fmtDateTime(ev.starts_at)}</td>
<td class="body-sm muted">{ev.location || '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=events&view=${ev.id}`} class="action-link label-sm">RSVPs</a>
<a href={`/admin?tab=events&edit=${ev.id}`} class="action-link label-sm">Edit</a>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_event" />
<input type="hidden" name="event_id" value={ev.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this event?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</div>
<style>
.back-link {
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
align-self: flex-start;
}
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.rsvp-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
margin: var(--space-4) 0 0;
}
.rsvp-summary div {
background: var(--surface-container-low);
padding: var(--space-5);
border-radius: var(--radius-md);
}
.rsvp-summary dt {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0 0 var(--space-2) 0;
}
.rsvp-summary dd {
font-family: var(--font-serif);
font-size: 2.5rem;
color: var(--on-surface);
margin: 0;
}
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.action-cell {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -1,261 +0,0 @@
---
import type { PulseRow, PulseWithCounts } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
interface Props {
pulses: PulseRow[];
editing: PulseRow | null;
viewing: PulseWithCounts | null;
}
const { pulses, editing, viewing } = Astro.props;
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
open: 'Open',
closed: 'Closed',
};
/** Convert SQL UTC date "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM" for datetime-local input. */
function toInputValue(sql: string | null | undefined): string {
if (!sql) return '';
return sql.replace(' ', 'T').slice(0, 16);
}
const formAction = editing ? 'update_pulse' : 'create_pulse';
const optionsForForm: string[] = editing ? [...editing.options] : [];
while (optionsForForm.length < 4) optionsForForm.push('');
---
<div class="tab-content">
{viewing ? (
<!-- ── Results view ─────────────────────────────────────────── -->
<section class="section">
<a href="/admin?tab=pulses" class="back-link label-sm">← Back to pulses</a>
<h2 class="label-sm section-heading">Results — {STATUS_LABEL[viewing.status]}</h2>
<p class="pulse-question-display">{viewing.question}</p>
{viewing.context && <p class="body-md muted">{viewing.context}</p>}
<p class="body-sm muted">Open {fmtDateTime(viewing.opens_at)} → {fmtDateTime(viewing.closes_at)} · {viewing.votes_total} vote{viewing.votes_total === 1 ? '' : 's'}</p>
<div class="results-grid">
{viewing.options.map((opt, i) => {
const count = viewing.votes_by_option[i] ?? 0;
const pct = viewing.votes_total > 0 ? (count / viewing.votes_total) * 100 : 0;
return (
<div class="result-row">
<div class="result-meta">
<span class="result-letter label-sm">{String.fromCharCode(65 + i)}</span>
<span class="result-text">{opt}</span>
<span class="result-count label-sm">{count} ({pct.toFixed(0)}%)</span>
</div>
<div class="result-bar"><span class="result-bar-fill" style={`width:${pct.toFixed(1)}%`}></span></div>
</div>
);
})}
</div>
</section>
) : (
<>
<!-- ── Create / edit form ──────────────────────────────────── -->
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit pulse' : 'New pulse'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="pulse_id" value={editing.id} />}
<div class="field">
<label for="question" class="label-sm field-label">Question</label>
<input type="text" id="question" name="question" class="input body-md" required value={editing?.question ?? ''} />
</div>
<div class="field">
<label for="context" class="label-sm field-label">Context (optional)</label>
<textarea id="context" name="context" class="input body-md" rows="3">{editing?.context ?? ''}</textarea>
</div>
<fieldset class="option-grid">
<legend class="label-sm field-label">Options (24)</legend>
{optionsForForm.map((val, i) => (
<input
type="text"
name={`option_${i}`}
placeholder={`Option ${String.fromCharCode(65 + i)}`}
class="input body-md"
value={val}
required={i < 2}
/>
))}
</fieldset>
<div class="form-grid">
<div class="field">
<label for="opens_at" class="label-sm field-label">Opens at (UTC)</label>
<input type="datetime-local" id="opens_at" name="opens_at" class="input body-md" required value={toInputValue(editing?.opens_at)} />
</div>
<div class="field">
<label for="closes_at" class="label-sm field-label">Closes at (UTC)</label>
<input type="datetime-local" id="closes_at" name="closes_at" class="input body-md" required value={toInputValue(editing?.closes_at)} />
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save as draft'}</button>
{!editing && (
<button type="submit" name="publish" value="1" class="btn-secondary label-sm">Save and publish now</button>
)}
{editing && (
<a href="/admin?tab=pulses" class="action-link label-sm">Cancel</a>
)}
</div>
</form>
</section>
<!-- ── List ────────────────────────────────────────────────── -->
<section class="section">
<h2 class="label-sm section-heading">All pulses</h2>
{pulses.length === 0 ? (
<p class="body-sm empty-msg">No pulses yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Question</th>
<th class="label-sm">Status</th>
<th class="label-sm">Opens / Closes</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{pulses.map(p => (
<tr>
<td class="body-sm">{p.question}</td>
<td class="body-sm"><span class:list={['status-pill', `status-${p.status}`]}>{STATUS_LABEL[p.status]}</span></td>
<td class="body-sm muted">{fmtDateTime(p.opens_at)} →<br />{fmtDateTime(p.closes_at)}</td>
<td class="action-cell">
<a href={`/admin?tab=pulses&view=${p.id}`} class="action-link label-sm">Results</a>
<a href={`/admin?tab=pulses&edit=${p.id}`} class="action-link label-sm">Edit</a>
{p.status === 'draft' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="publish_pulse" />
<input type="hidden" name="pulse_id" value={p.id} />
<button type="submit" class="action-link label-sm">Publish</button>
</form>
)}
{p.status === 'open' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="close_pulse" />
<input type="hidden" name="pulse_id" value={p.id} />
<button type="submit" class="action-link label-sm">Close</button>
</form>
)}
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_pulse" />
<input type="hidden" name="pulse_id" value={p.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this pulse and all votes?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</div>
<style>
.back-link {
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
align-self: flex-start;
}
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.pulse-question-display {
font-family: var(--font-serif);
font-style: italic;
font-size: 1.25rem;
color: var(--on-surface);
margin: var(--space-2) 0;
}
.results-grid { display: flex; flex-direction: column; gap: var(--space-4); margin-top: var(--space-4); }
.result-row { display: flex; flex-direction: column; gap: var(--space-2); }
.result-meta { display: flex; align-items: baseline; gap: var(--space-3); }
.result-letter { font-weight: 600; color: var(--on-surface-muted); width: 1.5rem; }
.result-text { flex: 1; color: var(--on-surface); }
.result-count { color: var(--on-surface-muted); letter-spacing: var(--tracking-wide); }
.result-bar { height: 4px; background: var(--surface-container); border-radius: var(--radius-full); overflow: hidden; }
.result-bar-fill { display: block; height: 100%; background: var(--pigment-terracotta); opacity: 0.6; }
.option-grid {
border: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.option-grid legend {
grid-column: 1 / -1;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
padding: 0;
margin-bottom: var(--space-1);
}
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.btn-secondary {
padding: var(--space-2) var(--space-6);
background: var(--surface-container);
color: var(--on-surface);
border: var(--ghost-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
}
.btn-secondary:hover { background: var(--surface-container-high); }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);
border-radius: var(--radius-full);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
.status-open { background: rgba(185, 107, 88, 0.12); color: var(--pigment-terracotta); font-weight: 600; }
.status-closed { background: var(--surface-container-low); color: var(--on-surface-muted); }
.action-cell {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -1,202 +0,0 @@
---
import type { RoadmapItemWithAttribution, UserPublic } from '../../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
editing: RoadmapItemWithAttribution | null;
cabUsers: UserPublic[];
}
const { items, editing, cabUsers } = Astro.props;
const STATUS_LABEL = {
shipping: 'Shipping',
in_beta: 'In beta',
exploring: 'Exploring',
considering: 'Considering',
} as const;
const formAction = editing ? 'update_roadmap' : 'create_roadmap';
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
in_beta: items.filter(i => i.status === 'in_beta' ).sort((a,b) => a.display_order - b.display_order),
exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order),
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
};
---
<div class="tab-content">
<!-- ── Form ──────────────────────────────────────────────────── -->
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit roadmap item' : 'New roadmap item'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="roadmap_id" value={editing.id} />}
<div class="form-grid">
<div class="field">
<label for="title" class="label-sm field-label">Title</label>
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="field">
<label for="status" class="label-sm field-label">Status</label>
<select id="status" name="status" class="select body-md" required>
<option value="considering" selected={editing?.status === 'considering'}>Considering</option>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="in_beta" selected={editing?.status === 'in_beta'}>In beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
</select>
</div>
<div class="field">
<label for="target" class="label-sm field-label">Target (free-form, e.g. Q3 2026)</label>
<input type="text" id="target" name="target" class="input body-md" value={editing?.target ?? ''} />
</div>
<div class="field">
<label for="display_order" class="label-sm field-label">Order (within status)</label>
<input type="number" id="display_order" name="display_order" class="input body-md" value={editing?.display_order ?? 0} />
</div>
</div>
<div class="field">
<label for="description" class="label-sm field-label">Description</label>
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div>
<div class="field">
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
<input
type="text"
id="metadata_text"
name="metadata_text"
class="input body-md"
value={editing?.metadata_text ?? ''}
placeholder="e.g. Open question on key custody · Council input wanted"
maxlength="120"
/>
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
</div>
<fieldset class="attribution-grid">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => (
<label class="check-row">
<input type="checkbox" name="attributed_user_ids" value={u.id} checked={attributedSet.has(u.id)} />
<span class="body-sm">{u.name} <span class="muted">— {u.organisation}</span></span>
</label>
))}
{cabUsers.length === 0 && <span class="body-sm muted">No council members yet.</span>}
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create item'}</button>
{editing && <a href="/admin?tab=roadmap" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','in_beta','exploring','considering'] as const).map(status => (
<section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? (
<p class="body-sm empty-msg">Nothing here yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Target</th>
<th class="label-sm">Attributed</th>
<th class="label-sm">Order</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{grouped[status].map((item, idx) => (
<tr>
<td class="body-sm">{item.title}</td>
<td class="body-sm muted">{item.target ?? '—'}</td>
<td class="body-sm muted">{item.attributed.length === 0 ? '—' : item.attributed.map(a => a.name.split(' ')[0]).join(', ')}</td>
<td class="body-sm muted">{item.display_order}</td>
<td class="action-cell">
{idx > 0 && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="move_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<input type="hidden" name="direction" value="up" />
<button type="submit" class="action-link label-sm" aria-label="Move up">↑</button>
</form>
)}
{idx < grouped[status].length - 1 && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="move_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<input type="hidden" name="direction" value="down" />
<button type="submit" class="action-link label-sm" aria-label="Move down">↓</button>
</form>
)}
<a href={`/admin?tab=roadmap&edit=${item.id}`} class="action-link label-sm">Edit</a>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this roadmap item?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
))}
</div>
<style>
.attribution-grid {
border: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
.attribution-grid legend {
grid-column: 1 / -1;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
padding: 0;
margin-bottom: var(--space-2);
}
.check-row { display: flex; align-items: center; gap: var(--space-2); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.action-cell {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style>

View file

@ -1,90 +0,0 @@
---
import type { UserPublic } from '../../lib/db';
import { readFocusTags } from '../../lib/format';
interface Props {
member: UserPublic;
}
const { member } = Astro.props;
const tagsStr = readFocusTags(member.focus_tags).join(', ');
---
<div class="tab-content">
<section class="section">
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="update_user_admin" />
<input type="hidden" name="user_id" value={member.id} />
<div class="form-grid">
<div class="field">
<label class="label-sm field-label">Name</label>
<input type="text" class="input body-md" value={member.name} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Email</label>
<input type="text" class="input body-md" value={member.email} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Organisation</label>
<input type="text" class="input body-md" value={member.organisation} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
</div>
<div class="field">
<label for="title" class="label-sm field-label">Job title</label>
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
</div>
<div class="field">
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
</div>
</div>
<div class="field">
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">Save changes</button>
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
</div>
</form>
<p class="body-sm note">
Role transitions and deactivation live in the participants table.
A member-number is allocated the first time a user becomes CAB and is never reused.
</p>
</section>
</div>
<script>
// Tiny live counter for the 200-char pull-quote field — no framework.
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
if (!counter) return;
const update = () => { counter.textContent = `${el.value.length} / 200`; };
el.addEventListener('input', update);
});
</script>
<style>
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
.note {
color: var(--on-surface-muted);
margin-top: var(--space-4);
max-width: var(--reading-max);
}
.input:disabled {
color: var(--on-surface-muted);
background: var(--surface-container-low);
cursor: not-allowed;
}
</style>

View file

@ -304,6 +304,15 @@ export function getAllInvites(): (Invite & { creator_name: string | null })[] {
`).all() as (Invite & { creator_name: string | null })[]; `).all() as (Invite & { creator_name: string | null })[];
} }
export function getInviteById(id: number): (Invite & { creator_name: string | null }) | null {
return db.prepare(`
SELECT i.*, u.name AS creator_name
FROM invites i
LEFT JOIN users u ON u.id = i.created_by_user_id
WHERE i.id = ?
`).get(id) as (Invite & { creator_name: string | null }) | null;
}
// ── Contributions ──────────────────────────────────────────────── // ── Contributions ────────────────────────────────────────────────
export function createContribution(data: { export function createContribution(data: {
@ -457,6 +466,20 @@ export function getAllJoinRequests(): JoinRequest[] {
`).all() as JoinRequest[]; `).all() as JoinRequest[];
} }
export function getJoinRequestById(id: number): JoinRequest | null {
return db.prepare(`
SELECT jr.id, jr.user_id, jr.created_at,
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
FROM join_requests jr
JOIN users u ON u.id = jr.user_id
WHERE jr.id = ?
`).get(id) as JoinRequest | null;
}
export function deleteJoinRequest(id: number): void {
db.prepare('DELETE FROM join_requests WHERE id = ?').run(id);
}
// ── Date helpers ───────────────────────────────────────────────── // ── Date helpers ─────────────────────────────────────────────────
/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */ /** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */

View file

@ -0,0 +1,253 @@
---
/* ---------------------------------------------------------------------------
* /admin/<resource> — the production dynamic admin route.
*
* Resolves the resource from the URL segment, gates on user.role === 'fenja',
* dispatches POSTs (save / delete / action key) through validateForResource
* and resource.ops, redirects with a ?msg=<key> flash on success.
*
* 404s when the resource key is not registered — step 8 onward populates
* the registry; until then most resource keys won't resolve.
* ------------------------------------------------------------------------- */
import AdminLayout from '../../admin/components/AdminLayout.astro';
import ResourceListView from '../../admin/components/ResourceListView.astro';
import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro';
import { groups } from '../../admin/resources';
import { validateForResource, type ValidationErrors } from '../../admin/validate';
import type {
ActionResult,
Field,
OpContext,
Resource,
} from '../../admin/resource-types';
// ── Auth gate ─────────────────────────────────────────────────────────────
const user = Astro.locals.user;
if (user.role !== 'fenja') return Astro.redirect('/');
// ── Resolve resource from URL segment ─────────────────────────────────────
const resourceKey = Astro.params.resource;
const allResources = groups.flatMap((g) => g.resources);
const resource = allResources.find((r) => r.key === resourceKey) as
| Resource
| undefined;
if (!resource) {
return new Response('Resource not found', { status: 404 });
}
const resourceBase = `/admin/${resource.key}`;
const opCtx: OpContext = { user: { id: user.id, role: user.role } };
// ── Form-data → typed record (driven by the field configs) ────────────────
function parseFormData(
formData: FormData,
fields: Field[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of fields) {
if (field.readOnly) continue;
switch (field.kind) {
case 'multi-text': {
out[field.key] = formData
.getAll(field.key)
.map((v) => String(v))
.filter((v) => v.trim() !== '');
break;
}
case 'multi-select-async': {
out[field.key] = formData.getAll(field.key).map((v) => {
const s = String(v);
const n = Number(s);
return Number.isFinite(n) && s !== '' ? n : s;
});
break;
}
case 'number': {
const v = formData.get(field.key);
if (v == null || v === '') {
out[field.key] = null;
} else {
const n = Number(v);
out[field.key] = Number.isFinite(n) ? n : v;
}
break;
}
default: {
const v = formData.get(field.key);
out[field.key] = v == null ? '' : String(v);
}
}
}
return out;
}
// ── State that survives a failed POST (so the panel re-fills) ─────────────
let errors: ValidationErrors = {};
let formError: string | null = null;
let resubmitValues: Record<string, unknown> | null = null;
// ── POST dispatch ────────────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const action = String(formData.get('_action') ?? 'save');
opCtx.formData = formData;
const editIdParam = Astro.url.searchParams.get('edit');
const editId =
editIdParam && Number.isFinite(Number(editIdParam))
? Number(editIdParam)
: null;
// ── save ──────────────────────────────────────────────────────────────
if (action === 'save') {
if (!resource.form) {
return new Response('Resource is read-only', { status: 403 });
}
const data = parseFormData(formData, resource.form.fields);
const existingItem =
editId !== null && resource.ops.getById
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
: null;
errors = validateForResource({
resource,
data,
item: existingItem,
actingUserId: user.id,
});
if (Object.keys(errors).length === 0) {
try {
if (editId !== null && resource.ops.update) {
await resource.ops.update(editId, data, opCtx);
const extra = resultRedirectParam(opCtx.result);
return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved${extra}`);
}
if (editId === null && resource.ops.create) {
const newId = await resource.ops.create(data, opCtx);
const extra = resultRedirectParam(opCtx.result);
return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created${extra}`);
}
return Astro.redirect(`${resourceBase}?msg=saved`);
} catch (err) {
formError = err instanceof Error ? err.message : 'Save failed';
resubmitValues = data;
}
} else {
resubmitValues = data;
}
}
// ── delete ────────────────────────────────────────────────────────────
else if (action === 'delete') {
if (editId !== null && resource.ops.delete) {
try {
await resource.ops.delete(editId, opCtx);
return Astro.redirect(`${resourceBase}?msg=deleted`);
} catch (err) {
formError = err instanceof Error ? err.message : 'Delete failed';
}
}
}
// ── custom action ─────────────────────────────────────────────────────
else {
const customAction = resource.actions?.find((a) => a.key === action);
if (customAction && editId !== null) {
try {
const direct = await customAction.handler(editId, opCtx);
// Handlers may set ctx.result or return an ActionResult — accept both.
const result = (direct as ActionResult | undefined) ?? opCtx.result;
const extra = resultRedirectParam(result);
// Some actions remove the item entirely (e.g. decline). Land on the
// list view in that case so we don't 404 trying to re-fetch the row.
const stillExists = resource.ops.getById
? (await resource.ops.getById(editId)) !== null
: true;
const target = stillExists
? `${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}${extra}`
: `${resourceBase}?msg=action_${encodeURIComponent(action)}${extra}`;
return Astro.redirect(target);
} catch (err) {
formError = err instanceof Error ? err.message : 'Action failed';
}
} else {
return new Response('Unknown action', { status: 400 });
}
}
}
function resultRedirectParam(r: ActionResult | undefined): string {
if (!r) return '';
if (r.kind === 'invite-link') {
return `&invite_url=${encodeURIComponent(r.url)}`;
}
return '';
}
// ── GET / failed-POST render ──────────────────────────────────────────────
const isNew = Astro.url.searchParams.get('new') === '1';
const editIdRaw = Astro.url.searchParams.get('edit');
const editId =
editIdRaw && Number.isFinite(Number(editIdRaw)) ? Number(editIdRaw) : null;
const editingItem =
editId !== null && resource.ops.getById
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
: null;
// Panel renders when:
// - editing/creating a form-bearing resource, OR
// - reviewing an item from a form-null resource that has a summary (e.g. join_requests)
const showPanel = resource.form !== null
? (isNew || editingItem !== null)
: (editingItem !== null && resource.summary !== undefined);
const msg = Astro.url.searchParams.get('msg');
const pageTitle = `${resource.pluralLabel} — Backstage`;
// Friendly flash text. Anything action_<key> is rendered as
// "<action.label> done." using the resource's action label.
function flashTextFor(rawMsg: string | null): string | null {
if (!rawMsg) return null;
if (formError) return formError;
if (rawMsg.startsWith('action_')) {
const key = rawMsg.slice('action_'.length);
const action = resource!.actions?.find((a) => a.key === key);
return action ? `${action.label}.` : null;
}
return ({
saved: 'Saved.',
created: 'Created.',
deleted: 'Deleted.',
} as Record<string, string>)[rawMsg] ?? null;
}
const flash = formError ?? flashTextFor(msg);
const flashKind = formError ? 'error' : 'success';
---
<AdminLayout
title={pageTitle}
groups={groups}
activeResourceKey={resource.key}
>
{flash && (
<div class:list={['bs-flash', flashKind]} role="status">{flash}</div>
)}
<ResourceListView resource={resource} groups={groups} />
{showPanel && (
<ResourceEditPanel
resource={resource}
item={editingItem}
formValues={resubmitValues ?? undefined}
errors={errors}
actingUserId={user.id}
/>
)}
</AdminLayout>

View file

@ -1,904 +1,17 @@
--- ---
import AppLayout from '../../layouts/AppLayout.astro'; /* ---------------------------------------------------------------------------
import { * /admin — redirect to the first registered resource.
getAllInvites, getAllUsersPublic, revokeInvite, *
createInvite, updateUserRole, deactivateUser, updateUserAdminFields, * Auth-gated like every other admin page. Members hitting /admin without
getUserPublicById, getAllJoinRequests, * the fenja role land on /; admins land on the dispatches list view (the
createPulse, updatePulse, publishPulse, closePulse, deletePulse, * default Backstage home).
getAllPulses, getPulseById, getPulseWithCounts, * ------------------------------------------------------------------------- */
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, import { groups } from '../../admin/resources';
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
getEventRsvpCount, getEventById,
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
recordActivity, getAllActivityForAdmin,
} from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown';
import { parseFocusTags } from '../../lib/format';
import { notifyPulseOpened } from '../../lib/notify';
import PulsesTab from '../../components/admin/PulsesTab.astro';
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
import EventsTab from '../../components/admin/EventsTab.astro';
import ActivityTab from '../../components/admin/ActivityTab.astro';
import DispatchesTab from '../../components/admin/DispatchesTab.astro';
import UserEditTab from '../../components/admin/UserEditTab.astro';
import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
if (user.role !== 'fenja') return Astro.redirect('/');
// Guard: fenja only const first = groups.flatMap((g) => g.resources)[0];
if (user.role !== 'fenja') { return Astro.redirect(first ? `/admin/${first.key}` : '/');
return Astro.redirect('/');
}
const tab = Astro.url.searchParams.get('tab') ?? 'invitations';
let newInviteToken: string | null = null;
let formError: string | null = null;
let actionMsg: string | null = null;
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'create_invite') {
const name = String(data.get('name') ?? '').trim();
const email = String(data.get('email') ?? '').trim().toLowerCase();
const organisation = String(data.get('organisation') ?? '').trim();
const role = String(data.get('role') ?? '') as Role;
if (!name || !email || !organisation || !['pilot','cab','fenja'].includes(role)) {
formError = 'All fields are required.';
} else {
const { token, tokenHash } = generateInviteToken();
createInvite({
token_hash: tokenHash,
email,
name,
organisation,
role,
expires_at: inviteExpiresAt(),
created_by_user_id: user.id,
});
newInviteToken = `${Astro.url.origin}/invite/${token}`;
}
} else if (action === 'revoke_invite') {
const id = Number(data.get('invite_id'));
if (id) revokeInvite(id);
return Astro.redirect('/admin?tab=invitations&msg=revoked');
} else if (action === 'change_role') {
const userId = Number(data.get('user_id'));
const newRole = String(data.get('role')) as Role;
if (userId && ['pilot','cab','fenja'].includes(newRole)) {
updateUserRole(userId, newRole);
}
return Astro.redirect('/admin?tab=participants&msg=updated');
} else if (action === 'deactivate_user') {
const userId = Number(data.get('user_id'));
if (userId && userId !== user.id) deactivateUser(userId);
return Astro.redirect('/admin?tab=participants&msg=deactivated');
// ── User profile edit (title / pull_quote / focus_tags) ─────
} else if (action === 'update_user_admin') {
const userId = Number(data.get('user_id'));
if (userId) {
const title = String(data.get('title') ?? '').trim() || null;
const pullQuote = String(data.get('pull_quote') ?? '').trim() || null;
const tagsInput = String(data.get('focus_tags') ?? '');
const focusTags = parseFocusTags(tagsInput);
updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags });
}
return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`);
// ── Dispatches ───────────────────────────────────────────────
} else if (action === 'create_dispatch' || action === 'update_dispatch') {
const title = String(data.get('title') ?? '').trim();
const body = String(data.get('body') ?? '');
const excerpt = String(data.get('excerpt') ?? '').trim() || null;
const kind = String(data.get('kind') ?? '') as DispatchKind;
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
// Parse optional poll attachment fields.
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
const pollQuestion = String(data.get('poll_question') ?? '').trim();
const pollOpts = [0, 1, 2, 3]
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
.filter(s => s.length > 0);
const pollOpens = String(data.get('poll_opens_at') ?? '');
const pollCloses = String(data.get('poll_closes_at') ?? '');
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
pollInput = {
question: pollQuestion,
options: pollOpts,
opens_at: toSqlDate(pollOpens),
closes_at: toSqlDate(pollCloses),
};
}
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, {
title, body, excerpt, kind, author_id: authorId || user.id,
poll: pollInput, pollExplicit,
});
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) publishDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published');
} else if (action === 'archive_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) archiveDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived');
} else if (action === 'delete_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) deleteDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted');
// ── Pulses ───────────────────────────────────────────────────
} else if (action === 'create_pulse' || action === 'update_pulse') {
const question = String(data.get('question') ?? '').trim();
const context = String(data.get('context') ?? '').trim() || null;
const opens_at = toSqlDate(String(data.get('opens_at') ?? ''));
const closes_at = toSqlDate(String(data.get('closes_at') ?? ''));
const publish = String(data.get('publish') ?? '') === '1';
const options = [0, 1, 2, 3]
.map(i => String(data.get(`option_${i}`) ?? '').trim())
.filter(s => s.length > 0);
if (!question || options.length < 2 || !opens_at || !closes_at) {
formError = 'Question, at least 2 options, and both dates are required.';
} else if (action === 'create_pulse') {
const id = createPulse({
question, context, options, opens_at, closes_at,
status: publish ? 'open' : 'draft',
created_by: user.id,
});
if (publish) {
recordActivity(user.id, 'pulse_opened', 'pulse', id);
const p = getPulseById(id);
if (p) notifyPulseOpened(p);
}
return Astro.redirect('/admin?tab=pulses&msg=pulse_created');
} else {
const id = Number(data.get('pulse_id'));
if (id) updatePulse(id, { question, context, options, opens_at, closes_at });
return Astro.redirect('/admin?tab=pulses&msg=pulse_updated');
}
} else if (action === 'publish_pulse') {
const id = Number(data.get('pulse_id'));
if (id) {
publishPulse(id);
recordActivity(user.id, 'pulse_opened', 'pulse', id);
const p = getPulseById(id);
if (p) notifyPulseOpened(p);
}
return Astro.redirect('/admin?tab=pulses&msg=pulse_published');
} else if (action === 'close_pulse') {
const id = Number(data.get('pulse_id'));
if (id) closePulse(id);
return Astro.redirect('/admin?tab=pulses&msg=pulse_closed');
} else if (action === 'delete_pulse') {
const id = Number(data.get('pulse_id'));
if (id) deletePulse(id);
return Astro.redirect('/admin?tab=pulses&msg=pulse_deleted');
// ── Roadmap ──────────────────────────────────────────────────
} else if (action === 'create_roadmap' || action === 'update_roadmap') {
const title = String(data.get('title') ?? '').trim();
const description = String(data.get('description') ?? '').trim();
const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0);
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
formError = 'Title and status are required.';
} else if (action === 'create_roadmap') {
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText });
setRoadmapAttributions(id, attributedIds);
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created');
} else {
const id = Number(data.get('roadmap_id'));
if (id) {
const { shippedNow } = updateRoadmapItem(id, {
title, description, status, target, display_order: displayOrder, metadata_text: metadataText,
});
setRoadmapAttributions(id, attributedIds);
if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
}
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_updated');
}
} else if (action === 'delete_roadmap') {
const id = Number(data.get('roadmap_id'));
if (id) deleteRoadmapItem(id);
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_deleted');
} else if (action === 'move_roadmap') {
const id = Number(data.get('roadmap_id'));
const dir = String(data.get('direction') ?? '');
if (id && (dir === 'up' || dir === 'down')) {
moveRoadmapItem(id, dir);
}
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_moved');
// ── Events ───────────────────────────────────────────────────
} else if (action === 'create_event' || action === 'update_event') {
const slug = String(data.get('slug') ?? '').trim().toLowerCase();
const title = String(data.get('title') ?? '').trim();
const kind = String(data.get('kind') ?? '') as EventKind;
const description = String(data.get('description') ?? '').trim();
const location = String(data.get('location') ?? '').trim();
const starts_at = toSqlDate(String(data.get('starts_at') ?? ''));
const ends_at = String(data.get('ends_at') ?? '').trim()
? toSqlDate(String(data.get('ends_at') ?? ''))
: null;
const capacity = Number(data.get('capacity') ?? 0) || null;
const photo_url = String(data.get('photo_url') ?? '').trim() || null;
if (!slug || !title || !starts_at || !['dinner','office_hours','summit','virtual'].includes(kind)) {
formError = 'Slug, title, kind, and start date are required.';
} else if (action === 'create_event') {
createEvent({ slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, created_by: user.id });
return Astro.redirect('/admin?tab=events&msg=event_created');
} else {
const id = Number(data.get('event_id'));
if (id) updateEvent(id, { title, kind, description, location, starts_at, ends_at, capacity, photo_url });
return Astro.redirect('/admin?tab=events&msg=event_updated');
}
} else if (action === 'delete_event') {
const id = Number(data.get('event_id'));
if (id) deleteEvent(id);
return Astro.redirect('/admin?tab=events&msg=event_deleted');
}
}
/** "2026-05-11T12:00" (datetime-local input) → "2026-05-11 12:00:00" (SQL UTC). */
function toSqlDate(input: string): string {
if (!input) return '';
// datetime-local format: YYYY-MM-DDTHH:MM (no timezone). Treat as UTC.
return input.replace('T', ' ') + (input.length === 16 ? ':00' : '');
}
/** Swap display_order with the neighbour in the same status column. */
function moveRoadmapItem(id: number, dir: 'up' | 'down'): void {
const all = getAllRoadmapItems();
const item = all.find(r => r.id === id);
if (!item) return;
const sameStatus = all
.filter(r => r.status === item.status)
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
const idx = sameStatus.findIndex(r => r.id === id);
const swapIdx = dir === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sameStatus.length) return;
const other = sameStatus[swapIdx];
updateRoadmapItem(item.id, {
title: item.title, description: item.description, status: item.status,
target: item.target, display_order: other.display_order, metadata_text: item.metadata_text,
});
updateRoadmapItem(other.id, {
title: other.title, description: other.description, status: other.status,
target: other.target, display_order: item.display_order, metadata_text: other.metadata_text,
});
}
const invites = getAllInvites();
const users = getAllUsersPublic();
const joinRequests = getAllJoinRequests();
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null;
const fenjaUsers = users.filter(u => u.role === 'fenja');
const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null;
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
// Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : [];
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
const pulseViewing = tab === 'pulses' && viewId ? getPulseWithCounts(viewId, user.id) : null;
const roadmapItems = tab === 'roadmap' ? getAllRoadmapItems() : [];
const roadmapEditing = tab === 'roadmap' && editId ? getRoadmapItem(editId) : null;
const cabUsers = tab === 'roadmap' ? users.filter(u => u.role === 'cab' || u.role === 'pilot') : [];
const events = tab === 'events' ? getAllEvents() : [];
const eventEditing = tab === 'events' && editId ? getEventById(editId) : null;
const eventViewing = tab === 'events' && viewId ? getEventById(viewId) : null;
const eventViewingRsvps = tab === 'events' && viewId && eventViewing
? getEventRsvpCount(eventViewing.slug)
: null;
const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : [];
const MSGS: Record<string, string> = {
revoked: 'Invite revoked.',
updated: 'Role updated.',
deactivated: 'User deactivated.',
user_updated: 'Member profile updated.',
pulse_created: 'Pulse saved.',
pulse_updated: 'Pulse updated.',
pulse_published: 'Pulse published — members notified.',
pulse_closed: 'Pulse closed.',
pulse_deleted: 'Pulse deleted.',
roadmap_created: 'Roadmap item saved.',
roadmap_updated: 'Roadmap item updated.',
roadmap_deleted: 'Roadmap item deleted.',
roadmap_moved: 'Roadmap reordered.',
event_created: 'Event saved.',
event_updated: 'Event updated.',
event_deleted: 'Event deleted.',
dispatch_created: 'Dispatch saved.',
dispatch_updated: 'Dispatch updated.',
dispatch_published: 'Dispatch published.',
dispatch_archived: 'Dispatch archived.',
dispatch_deleted: 'Dispatch deleted.',
};
actionMsg = Astro.url.searchParams.get('msg');
--- ---
<AppLayout title="Admin" user={user}>
<div class="page">
<header class="page-header">
<p class="label-sm eyebrow">Admin</p>
<h1 class="display-md page-title">Control panel.</h1>
</header>
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
<div class="tabs">
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
</a>
<a href="/admin?tab=activity" class:list={['tab label-sm', { active: tab === 'activity' }]}>Activity</a>
</div>
{actionMsg && (
<p class="action-msg body-sm" role="status">
{MSGS[actionMsg] ?? ''}
</p>
)}
{formError && (
<p class="form-error body-sm" role="alert">{formError}</p>
)}
<!-- Invitations tab -->
{tab === 'invitations' && (
<div class="tab-content">
{/* New invite form */}
<section class="section">
<h2 class="label-sm section-heading">Generate invite link</h2>
{formError && (
<p class="form-error body-sm" role="alert">{formError}</p>
)}
{newInviteToken && (
<div class="invite-result">
<p class="label-sm invite-result-label">Copy this link and send it personally. It expires in 14 days and is single-use.</p>
<div class="invite-link-row">
<code class="invite-link body-sm">{newInviteToken}</code>
<button
type="button"
class="copy-btn label-sm"
data-copy={newInviteToken}
onclick="navigator.clipboard.writeText(this.dataset.copy);this.textContent='Copied'"
>
Copy
</button>
</div>
</div>
)}
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="create_invite" />
<div class="form-grid">
<div class="field">
<label for="name" class="label-sm field-label">Name</label>
<input type="text" id="name" name="name" class="input body-md" required />
</div>
<div class="field">
<label for="email" class="label-sm field-label">Email</label>
<input type="email" id="email" name="email" class="input body-md" required />
</div>
<div class="field">
<label for="organisation" class="label-sm field-label">Organisation</label>
<input type="text" id="organisation" name="organisation" class="input body-md" required />
</div>
<div class="field">
<label for="role" class="label-sm field-label">Role</label>
<select id="role" name="role" class="select body-md" required>
<option value="pilot">Pilot</option>
<option value="cab">CAB</option>
<option value="fenja">Fenja</option>
</select>
</div>
</div>
<button type="submit" class="btn-primary label-sm">Generate link</button>
</form>
</section>
{/* Invite table */}
<section class="section">
<h2 class="label-sm section-heading">Outstanding invites</h2>
{invites.filter((i) => !i.used_at).length === 0 ? (
<p class="body-sm empty-msg">No outstanding invites.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Role</th>
<th class="label-sm">Expires</th>
<th class="label-sm">Action</th>
</tr>
</thead>
<tbody>
{invites.filter((i) => !i.used_at).map((invite) => (
<tr>
<td class="body-sm">{invite.name}</td>
<td class="body-sm">{invite.email}</td>
<td class="body-sm">{invite.organisation}</td>
<td class="body-sm" style="text-transform:capitalize">{invite.role}</td>
<td class="body-sm">{fmtDate(invite.expires_at)}</td>
<td>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="revoke_invite" />
<input type="hidden" name="invite_id" value={invite.id} />
<button type="submit" class="danger-btn label-sm">Revoke</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)}
<!-- Participants tab -->
{tab === 'participants' && editingUser && (
<UserEditTab member={editingUser} />
)}
{tab === 'participants' && !editingUser && (
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">All participants</h2>
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Role</th>
<th class="label-sm">Last seen</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr class:list={[{ self: u.id === user.id }]}>
<td class="body-sm">{u.name}</td>
<td class="body-sm">{u.email}</td>
<td class="body-sm">{u.organisation}</td>
<td>
{u.id !== user.id ? (
<form method="POST" class="inline-form role-form">
<input type="hidden" name="action" value="change_role" />
<input type="hidden" name="user_id" value={u.id} />
<select name="role" class="select-inline label-sm" onchange="this.form.submit()">
<option value="pilot" selected={u.role === 'pilot'}>Pilot</option>
<option value="cab" selected={u.role === 'cab'}>CAB</option>
<option value="fenja" selected={u.role === 'fenja'}>Fenja</option>
</select>
</form>
) : (
<span class="body-sm" style="text-transform:capitalize">{u.role}</span>
)}
</td>
<td class="body-sm muted">
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
</td>
<td class="action-cell">
<a href={`/admin?tab=participants&edit=${u.id}`} class="action-link label-sm">Edit</a>
{u.id !== user.id && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="deactivate_user" />
<input type="hidden" name="user_id" value={u.id} />
<button type="submit" class="danger-btn label-sm"
onclick="return confirm('Deactivate this user?')">
Deactivate
</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
)}
<!-- Join requests tab -->
{tab === 'join' && (
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">Join requests</h2>
<p class="body-sm section-note">
Users who clicked "I want to join" on the home page. Use this to prioritise
follow-up and generate invite links.
</p>
{joinRequests.length === 0 ? (
<p class="body-sm empty-msg">No join requests yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Name</th>
<th class="label-sm">Email</th>
<th class="label-sm">Organisation</th>
<th class="label-sm">Requested</th>
</tr>
</thead>
<tbody>
{joinRequests.map((jr) => (
<tr>
<td class="body-sm">{jr.user_name}</td>
<td class="body-sm">{jr.user_email}</td>
<td class="body-sm">{jr.user_organisation}</td>
<td class="body-sm muted">{fmtDate(jr.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)}
{tab === 'pulses' && (
<PulsesTab pulses={pulses} editing={pulseEditing} viewing={pulseViewing} />
)}
{tab === 'roadmap' && (
<RoadmapTab items={roadmapItems} editing={roadmapEditing} cabUsers={cabUsers} />
)}
{tab === 'events' && (
<EventsTab events={events} editing={eventEditing} viewing={eventViewing} viewingRsvps={eventViewingRsvps} />
)}
{tab === 'activity' && (
<ActivityTab rows={activityRows} />
)}
{tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)}
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-8);
}
.eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.page-title { margin: 0; }
/* ── Tabs ────────────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: var(--space-1);
margin-bottom: var(--space-8);
border-bottom: var(--ghost-border);
padding-bottom: var(--space-2);
}
.tab {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
.tab.active { color: var(--on-surface); background: var(--surface-container); }
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--secondary);
color: var(--on-secondary);
border-radius: var(--radius-full);
font-size: var(--text-label-sm);
font-weight: 700;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 var(--space-1);
margin-left: var(--space-2);
}
.section-note {
color: var(--on-surface-muted);
margin: 0;
max-width: var(--reading-max);
}
/* ── Messages ────────────────────────────────────────────────────── */
.action-msg {
padding: var(--space-3) var(--space-4);
background: rgba(109, 140, 124, 0.1);
border-radius: var(--radius-sm);
color: var(--pigment-copper);
margin-bottom: var(--space-6);
}
.form-error {
padding: var(--space-3) var(--space-4);
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-sm);
color: var(--pigment-terracotta);
margin-bottom: var(--space-4);
}
/* ── Tab content ─────────────────────────────────────────────────── */
.tab-content {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
/* ── Section ─────────────────────────────────────────────────────── */
.section {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.section-heading {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.empty-msg {
color: var(--on-surface-muted);
margin: 0;
}
/* ── Invite result ───────────────────────────────────────────────── */
.invite-result {
background: rgba(109, 140, 124, 0.08);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.invite-result-label {
color: var(--pigment-copper);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.invite-link-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.invite-link {
font-family: var(--font-mono);
background: var(--background);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
color: var(--on-surface);
word-break: break-all;
flex: 1;
}
.copy-btn {
padding: var(--space-2) var(--space-3);
background: var(--secondary);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-sans);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Invite form ─────────────────────────────────────────────────── */
.invite-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 48rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field-label {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.input,
.select {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--surface-container-lowest);
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-body-md);
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;
}
.input:focus,
.select:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.btn-primary {
align-self: flex-start;
padding: var(--space-2) var(--space-6);
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover { opacity: 0.9; }
/* ── Data table ──────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
padding: var(--space-2) var(--space-3) var(--space-2) 0;
border-bottom: var(--ghost-border);
font-weight: 500;
}
.data-table td {
padding: var(--space-3) var(--space-3) var(--space-3) 0;
border-bottom: var(--ghost-border);
color: var(--on-surface-variant);
vertical-align: middle;
}
.data-table tr.self td {
color: var(--on-surface-muted);
}
.muted { color: var(--on-surface-muted) !important; }
/* ── Inline elements ─────────────────────────────────────────────── */
.inline-form { display: inline; }
.select-inline {
background: none;
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
padding: 0.2em var(--space-3);
cursor: pointer;
outline: none;
}
.danger-btn {
background: none;
border: none;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--pigment-terracotta);
padding: 0.2em var(--space-2);
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-standard);
}
.danger-btn:hover {
background: rgba(185, 107, 88, 0.08);
}
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -0,0 +1,168 @@
/* ---------------------------------------------------------------------------
* Verifier for the Backstage admin resource registry.
*
* Walks every registered resource and asserts the invariants that keep the
* shared components renderable. Compile-time TypeScript already catches most
* shape issues via the strict Resource<T> generic this suite covers what
* TS can't see at the value level (function-ness of handlers, kind strings
* actually being in the registered set, sentinel resource keys not colliding).
*
* Note on "every column.key is a valid field on the entity":
* That's a structural assertion best enforced at compile time. Resource<T>
* narrows the render/value callbacks to the entity's keys; this suite skips
* trying to re-check it at runtime.
* ------------------------------------------------------------------------- */
import { describe, it, expect } from 'vitest';
import { groups } from '../src/admin/resources';
import type {
Field,
Resource,
ResourceGroup,
Column,
FormEmbed,
} from '../src/admin/resource-types';
const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
'text',
'textarea',
'markdown',
'select',
'select-async',
'multi-select-async',
'multi-text',
'date',
'datetime',
'number',
'readonly',
]);
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
'text', 'pill', 'relative-date', 'number', 'tag-list',
]);
const KNOWN_EMBED_COMPONENTS: ReadonlySet<FormEmbed['component']> = new Set([
'pulse-sub-form',
]);
function allResources(): Resource[] {
return groups.flatMap((g: ResourceGroup) => g.resources as Resource[]);
}
describe('admin resource registry', () => {
it('has at least one group with resources registered', () => {
expect(groups.length).toBeGreaterThan(0);
expect(allResources().length).toBeGreaterThan(0);
});
it('every resource key is unique across the registry', () => {
const keys = allResources().map((r) => r.key);
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
expect(dups).toEqual([]);
});
it('every resource.groupKey points at a real group', () => {
const groupKeys = new Set(groups.map((g) => g.key));
for (const r of allResources()) {
expect(groupKeys.has(r.groupKey), `${r.key} → unknown groupKey ${r.groupKey}`).toBe(true);
}
});
describe.each(allResources())('resource: $key', (resource: Resource) => {
it('has required identity fields', () => {
expect(resource.key).toBeTruthy();
expect(resource.label).toBeTruthy();
expect(resource.pluralLabel).toBeTruthy();
expect(resource.singularLabel).toBeTruthy();
expect(resource.groupKey).toBeTruthy();
});
it('list.queryFn is a function', () => {
expect(typeof resource.list.queryFn).toBe('function');
});
it('every column has a registered kind (or none = text)', () => {
for (const col of resource.list.columns as Column<unknown>[]) {
const kind = col.kind ?? 'text';
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: column ${col.key} → unknown kind ${kind}`).toBe(true);
}
if (resource.list.columnsByFilter) {
for (const [filterKey, cols] of Object.entries(resource.list.columnsByFilter)) {
for (const col of cols as Column<unknown>[]) {
const kind = col.kind ?? 'text';
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: columnsByFilter.${filterKey}.${col.key} → unknown kind ${kind}`).toBe(true);
}
}
}
});
it('exactly one or zero filters is isDefault', () => {
const filters = resource.list.filters ?? [];
const defaults = filters.filter((f) => f.isDefault);
expect(defaults.length).toBeLessThanOrEqual(1);
});
it('every filter.predicate is a function', () => {
for (const f of resource.list.filters ?? []) {
expect(typeof f.predicate, `${resource.key}: filter ${f.key} predicate`).toBe('function');
}
});
it('every form field has a registered kind', () => {
for (const field of resource.form?.fields ?? []) {
expect(
KNOWN_FIELD_KINDS.has(field.kind),
`${resource.key}: field ${field.key} → unknown kind ${field.kind}`,
).toBe(true);
}
});
it('every embed.component is in the registered set', () => {
for (const embed of resource.form?.embeds ?? []) {
expect(
KNOWN_EMBED_COMPONENTS.has(embed.component),
`${resource.key}: embed ${embed.key} → unknown component ${embed.component}`,
).toBe(true);
}
});
it('every ops member is a function (when defined)', () => {
const ops = resource.ops;
if (ops.create) expect(typeof ops.create).toBe('function');
if (ops.update) expect(typeof ops.update).toBe('function');
if (ops.delete) expect(typeof ops.delete).toBe('function');
if (ops.getById) expect(typeof ops.getById).toBe('function');
});
it('every action.handler is a function', () => {
for (const action of resource.actions ?? []) {
expect(typeof action.handler, `${resource.key}: action ${action.key}`).toBe('function');
}
});
it('action keys are unique', () => {
const keys = (resource.actions ?? []).map((a) => a.key);
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
expect(dups).toEqual([]);
});
it('renders SOMETHING when an item is clicked (form OR summary, or no clicks)', () => {
// If form is null and there's no summary, the resource is non-clickable.
// If form is null but a summary is defined → review panel renders.
// If form is defined → edit panel renders.
// The only invalid shape is form=null + summary defined + no actions,
// which would render an empty review panel. Flag it.
if (resource.form === null && resource.summary !== undefined) {
const actions = resource.actions ?? [];
expect(actions.length, `${resource.key}: review-mode resource with no actions`).toBeGreaterThan(0);
}
});
it('when ops.create is defined, the form is defined too', () => {
// Can't render the create panel without a form.
if (resource.ops.create) {
expect(resource.form, `${resource.key}: ops.create is defined but form is null`).not.toBeNull();
}
});
});
});

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { validateForResource } from '../src/admin/validate';
import type { Resource } from '../src/admin/resource-types';
// Minimal resource fixture covering the field kinds the validator handles.
const resource: Resource = {
key: 'fixtures',
label: 'Fixture',
pluralLabel: 'Fixtures',
singularLabel: 'Fixture',
groupKey: 'system',
list: { queryFn: () => [], columns: [] },
ops: {},
form: {
fields: [
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 5 },
{ key: 'kind', label: 'Kind', kind: 'select', options: [{ value: 'a', label: 'A' }] },
{
key: 'options',
label: 'Options',
kind: 'multi-text',
minItems: 2,
maxItems: 4,
},
{ key: 'count', label: 'Count', kind: 'number', min: 0, max: 10 },
{
key: 'extras',
label: 'Extras',
kind: 'text',
// Only visible when kind === 'a'
visibleWhen: (ctx) => ctx.formValues.kind === 'a',
required: true,
},
],
},
};
const baseArgs = { resource, item: null, actingUserId: 1 };
describe('validateForResource', () => {
it('reports required fields that are empty', () => {
const errors = validateForResource({ ...baseArgs, data: {} });
expect(errors.title).toBe('Title is required');
});
it('reports maxLength violations on text fields', () => {
const errors = validateForResource({ ...baseArgs, data: { title: 'too long' } });
expect(errors.title).toMatch(/5 characters or fewer/);
});
it('skips fields hidden by visibleWhen', () => {
// kind = 'b' hides the 'extras' required field — no error expected
const errors = validateForResource({
...baseArgs,
data: { title: 'ok', kind: 'b' },
});
expect(errors.extras).toBeUndefined();
});
it('enforces visibleWhen-revealed required fields', () => {
const errors = validateForResource({
...baseArgs,
data: { title: 'ok', kind: 'a' },
});
expect(errors.extras).toBe('Extras is required');
});
it('enforces multi-text min/max items', () => {
const tooFew = validateForResource({
...baseArgs,
data: { title: 'ok', options: ['only-one'] },
});
expect(tooFew.options).toMatch(/at least 2/);
const tooMany = validateForResource({
...baseArgs,
data: { title: 'ok', options: ['1', '2', '3', '4', '5'] },
});
expect(tooMany.options).toMatch(/at most 4/);
});
it('enforces number min/max', () => {
const tooHigh = validateForResource({
...baseArgs,
data: { title: 'ok', count: 99 },
});
expect(tooHigh.count).toMatch(/no more than 10/);
});
it('returns no errors when every field is valid', () => {
const errors = validateForResource({
...baseArgs,
data: {
title: 'ok',
kind: 'a',
options: ['x', 'y'],
count: 5,
extras: 'fine',
},
});
expect(errors).toEqual({});
});
it('treats form: null as always valid', () => {
const readOnly: Resource = { ...resource, form: null };
const errors = validateForResource({
...baseArgs,
resource: readOnly,
data: {},
});
expect(errors).toEqual({});
});
});