From dd9ea68faba8b1f942112f0ea136ff728cb97a6a Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 16:24:13 +0200 Subject: [PATCH] feat(admin): publishing-group resources (dispatches, roadmap, events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/admin/admin.css | 15 ++ src/admin/components/ListCell.astro | 8 +- src/admin/components/ResourceEditPanel.astro | 14 +- src/admin/embeds/PulseSubForm.astro | 101 +++++++ src/admin/resource-types.ts | 14 +- src/admin/resources/dispatches.ts | 260 +++++++++++++++++++ src/admin/resources/events.ts | 250 ++++++++++++++++++ src/admin/resources/index.ts | 12 +- src/admin/resources/roadmap.ts | 185 +++++++++++++ src/pages/admin/[resource].astro | 1 + 10 files changed, 841 insertions(+), 19 deletions(-) create mode 100644 src/admin/embeds/PulseSubForm.astro create mode 100644 src/admin/resources/dispatches.ts create mode 100644 src/admin/resources/events.ts create mode 100644 src/admin/resources/roadmap.ts diff --git a/src/admin/admin.css b/src/admin/admin.css index d44b19f..2047048 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -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; } +} diff --git a/src/admin/components/ListCell.astro b/src/admin/components/ListCell.astro index 1c669f1..8d6ece2 100644 --- a/src/admin/components/ListCell.astro +++ b/src/admin/components/ListCell.astro @@ -38,7 +38,7 @@ let pillLabel: string | null = null; let pillClass: string | null = null; if (kind === 'pill') { const col = column as Extract; - const raw = col.valueOf ? col.valueOf(item) : (item[column.key] as string | undefined); + 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; - 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; - 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; - tags = col.valueOf(item); + tags = col.value(item); } --- diff --git a/src/admin/components/ResourceEditPanel.astro b/src/admin/components/ResourceEditPanel.astro index 79716cf..02777be 100644 --- a/src/admin/components/ResourceEditPanel.astro +++ b/src/admin/components/ResourceEditPanel.astro @@ -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 (

{embed.title}

- {/* - * Embed components are resolved per-resource. The pulse-sub-form - * renderer is wired in when the dispatches resource lands in step 8. - */} -

- {embed.component} renderer will be wired in step 8. -

+ {embed.component === 'pulse-sub-form' && ( + + )}
); })} diff --git a/src/admin/embeds/PulseSubForm.astro b/src/admin/embeds/PulseSubForm.astro new file mode 100644 index 0000000..4b78b95 --- /dev/null +++ b/src/admin/embeds/PulseSubForm.astro @@ -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 | 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] : ''; +} +--- + +
+

+ 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. +

+ +
+ + +
+ +
+ +
+
+ {initialOptions.map((opt, i) => ( +
+ + +
+ ))} +
+ +
+
+ +
+
+ + +
+
+ + +
+
+
diff --git a/src/admin/resource-types.ts b/src/admin/resource-types.ts index 60aa094..9d06a94 100644 --- a/src/admin/resource-types.ts +++ b/src/admin/resource-types.ts @@ -135,22 +135,22 @@ export interface PillColumn 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 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 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 extends ColumnBase { kind: 'tag-list'; - valueOf: (item: T) => string[]; + value: (item: T) => string[]; } export type Column = @@ -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 ───────────────────────────────────────────────────────── diff --git a/src/admin/resources/dispatches.ts b/src/admin/resources/dispatches.ts new file mode 100644 index 0000000..4057d23 --- /dev/null +++ b/src/admin/resources/dispatches.ts @@ -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 = { + 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); + }, + }, + ], +}; diff --git a/src/admin/resources/events.ts b/src/admin/resources/events.ts new file mode 100644 index 0000000..483a49c --- /dev/null +++ b/src/admin/resources/events.ts @@ -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 = { + 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 }; diff --git a/src/admin/resources/index.ts b/src/admin/resources/index.ts index 4a62b69..589b6b9 100644 --- a/src/admin/resources/index.ts +++ b/src/admin/resources/index.ts @@ -2,14 +2,20 @@ * Resource registry — single source of truth for sidebar navigation. * * Groups are populated incrementally across steps 8–10 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: [] }, ]; diff --git a/src/admin/resources/roadmap.ts b/src/admin/resources/roadmap.ts new file mode 100644 index 0000000..01c0450 --- /dev/null +++ b/src/admin/resources/roadmap.ts @@ -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 = { + 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), + }, +}; diff --git a/src/pages/admin/[resource].astro b/src/pages/admin/[resource].astro index a026209..1059663 100644 --- a/src/pages/admin/[resource].astro +++ b/src/pages/admin/[resource].astro @@ -92,6 +92,7 @@ let resubmitValues: Record | 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 =