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>
This commit is contained in:
Jonathan Hvid 2026-05-12 16:24:13 +02:00
parent 3aaa21e6af
commit dd9ea68fab
10 changed files with 841 additions and 19 deletions

View file

@ -1323,3 +1323,18 @@
background: rgba(185, 107, 88, 0.10);
color: var(--pigment-terracotta);
}
/* ── Embedded sub-form internals (pulse fieldset etc.) ──────────── */
.bs-pulse-embed {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.bs-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
@media (max-width: 767px) {
.bs-field-row { grid-template-columns: 1fr; }
}

View file

@ -38,7 +38,7 @@ let pillLabel: string | null = null;
let pillClass: string | null = null;
if (kind === 'pill') {
const col = column as Extract<typeof column, { kind: 'pill' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | undefined);
const raw = col.value ? col.value(item) : (item[column.key] as string | undefined);
if (raw) {
const variant = col.pillVariants[raw];
if (variant) {
@ -56,7 +56,7 @@ let relText: string | null = null;
let relEmpty: string | null = null;
if (kind === 'relative-date') {
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | null | undefined);
const raw = col.value ? col.value(item) : (item[column.key] as string | null | undefined);
if (raw) {
relText = relativeTime(raw);
} else {
@ -68,7 +68,7 @@ if (kind === 'relative-date') {
let numberText: string | null = null;
if (kind === 'number') {
const col = column as Extract<typeof column, { kind: 'number' }>;
const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as number | null | undefined);
const raw = col.value ? col.value(item) : (item[column.key] as number | null | undefined);
numberText = raw == null ? '—' : String(raw);
}
@ -76,7 +76,7 @@ if (kind === 'number') {
let tags: string[] = [];
if (kind === 'tag-list') {
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
tags = col.valueOf(item);
tags = col.value(item);
}
---

View file

@ -11,6 +11,7 @@
* ------------------------------------------------------------------------- */
import FieldRenderer from './FieldRenderer.astro';
import PulseSubForm from '../embeds/PulseSubForm.astro';
import type { Field, FieldContext, Resource } from '../resource-types';
interface Props {
@ -104,16 +105,13 @@ const formAction = Astro.url.pathname + Astro.url.search;
{embeds.length > 0 && embeds.map((embed) => {
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
return show && (
if (!show) return null;
return (
<section class="bs-embed" data-embed={embed.key}>
<h3 class="bs-embed-title">{embed.title}</h3>
{/*
* Embed components are resolved per-resource. The pulse-sub-form
* renderer is wired in when the dispatches resource lands in step 8.
*/}
<p class="bs-embed-placeholder">
<em>{embed.component} renderer will be wired in step 8.</em>
</p>
{embed.component === 'pulse-sub-form' && (
<PulseSubForm item={item} />
)}
</section>
);
})}

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>

View file

@ -135,22 +135,22 @@ export interface PillColumn<T> extends ColumnBase {
kind: 'pill';
pillVariants: PillVariants;
/** Override which value to look up in pillVariants (default = item[key]). */
valueOf?: (item: T) => string;
value?: (item: T) => string;
}
export interface RelativeDateColumn<T> extends ColumnBase {
kind: 'relative-date';
/** Shown when the value is null/undefined. */
emptyFallback?: string;
valueOf?: (item: T) => string | null | undefined;
value?: (item: T) => string | null | undefined;
}
export interface NumberColumn<T> extends ColumnBase {
kind: 'number';
valueOf?: (item: T) => number | null | undefined;
value?: (item: T) => number | null | undefined;
}
/** Compact list of pills — for focus_tags, audience, etc. */
export interface TagListColumn<T> extends ColumnBase {
kind: 'tag-list';
valueOf: (item: T) => string[];
value: (item: T) => string[];
}
export type Column<T> =
@ -221,6 +221,12 @@ export interface FormConfig {
// ── 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;
}
// ── CRUD operations ─────────────────────────────────────────────────────────

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

@ -2,14 +2,20 @@
* Resource registry single source of truth for sidebar navigation.
*
* Groups are populated incrementally across steps 810 of the Backstage
* rebuild. Empty registration is intentional during the shell-only phase;
* AdminLayout renders the empty state until the first resource lands.
* 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';
export const groups: ResourceGroup[] = [
{ key: 'publishing', label: 'Publishing', resources: [] },
{
key: 'publishing',
label: 'Publishing',
resources: [dispatchesResource, roadmapResource, eventsResource],
},
{ key: 'council', label: 'The council', resources: [] },
{ key: 'system', label: 'System', resources: [] },
];

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

@ -92,6 +92,7 @@ let resubmitValues: Record<string, unknown> | null = null;
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 =