Compare commits

..

No commits in common. "5702859e3784fec7a975d3e609b7fa54996d27f6" and "66c3f6492f9bf4f3cb533007c566e8f17b4eaf83" have entirely different histories.

80 changed files with 2604 additions and 8797 deletions

View file

@ -44,24 +44,7 @@
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)", "Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
"Bash(pnpm db:seed)", "Bash(pnpm db:seed)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)", "Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)", "Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
"Bash(awk -F: '{print $1}')",
"Bash(chmod +x /home/jonathan/.claude/statusline-command.sh)",
"Bash(bash -n /home/jonathan/.claude/statusline-command.sh)",
"Bash(command -v python3)",
"Bash(command -v python)",
"Bash(cd *)",
"Bash(break)",
"Bash(sed -n \"/export function createDispatch/,/^}/p\" src/lib/db.ts)",
"Bash(sed -n \"/export function dispatchSlug/,/^}/p\" src/lib/format.ts)",
"Bash(git check-ignore *)",
"Bash(sed -i -E 's/font-size: 15px/font-size: 16px/g; s/font-size: 14px/font-size: 15px/g; s/font-size: 13px/font-size: 14px/g; s/font-size: 12px/font-size: 13px/g; s/font-size: 11px/font-size: 12px/g; s/font-size: 10px/font-size: 11px/g; s/font-size: 9px/font-size: 10px/g' src/components/EventHeroCard.astro)",
"Bash(sed -n '/@media \\(max-width: 880px\\)/,/^ }/p' src/components/EventHeroCard.astro)",
"Bash(sed -n '1,80p' src/middleware.ts)",
"Bash(sed -n '1,80p' src/middleware/index.ts)",
"Bash(sed -n '1,40p' src/pages/api/join.ts)"
] ]
} }
} }

3
.gitignore vendored
View file

@ -8,6 +8,3 @@ node_modules/
*.db-shm *.db-shm
*.db-wal *.db-wal
progress.md progress.md
# Uploaded event photos (runtime, persists on the VPS)
data/uploads/

View file

@ -255,7 +255,6 @@ Listed so Claude Code does not wander into them:
- Email sending (invites are copied manually; no transactional email provider) - Email sending (invites are copied manually; no transactional email provider)
- File uploads from participants (they write text; Fenja attaches documents to meetings via git) - File uploads from participants (they write text; Fenja attaches documents to meetings via git)
- *Exception (added v1):* Fenja admins may upload a single event photo (png/jpg) per event via the admin panel. Stored on the VPS and served by the app. This is the only upload path; participants still cannot upload.
- Rich-text editor beyond markdown-lite - Rich-text editor beyond markdown-lite
- Threaded discussion on contributions (replies from Fenja only, one level deep) - Threaded discussion on contributions (replies from Fenja only, one level deep)
- Dark mode - Dark mode

View file

@ -1,10 +0,0 @@
-- Polls are no longer a standalone entity in the UX: every poll is attached
-- to a dispatch. We keep the pulses + votes tables (vote uniqueness, status
-- derivation, admin history) and add a nullable FK from dispatches.
--
-- ON DELETE SET NULL — if an attached pulse is hard-deleted, the dispatch
-- survives without a poll rather than vanishing with it.
ALTER TABLE dispatches ADD COLUMN pulse_id INTEGER REFERENCES pulses(id) ON DELETE SET NULL;
CREATE INDEX idx_dispatches_pulse ON dispatches(pulse_id) WHERE pulse_id IS NOT NULL;

View file

@ -1,40 +0,0 @@
-- Roadmap status enum gains a fourth value `considering` for items that are
-- under discussion but not yet committed to. Same migration also renames
-- the existing `beta` value to `in_beta` so the canonical names line up
-- with the v4 spec (no second display label layer needed).
--
-- SQLite can't widen a CHECK constraint in place, so this is a full table
-- rebuild. roadmap_attributions has an ON DELETE CASCADE FK to
-- roadmap_items(id), so foreign keys are toggled off around the rebuild to
-- preserve attribution rows across the DROP/RENAME.
PRAGMA foreign_keys = OFF;
CREATE TABLE roadmap_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring'
CHECK(status IN ('shipping','in_beta','exploring','considering')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO roadmap_items_new
(id, title, description, status, target, display_order, shipped_at, created_at, updated_at)
SELECT
id, title, description,
CASE status WHEN 'beta' THEN 'in_beta' ELSE status END,
target, display_order, shipped_at, created_at, updated_at
FROM roadmap_items;
DROP TABLE roadmap_items;
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
PRAGMA foreign_keys = ON;

View file

@ -1,6 +0,0 @@
-- Roadmap items gain an optional metadata_text field — a short admin-set
-- narrative cue shown in the route card's hover expansion. Free-form,
-- ~60 chars suggested in admin helper text. NULL when not set; UI hides
-- the line in that case.
ALTER TABLE roadmap_items ADD COLUMN metadata_text TEXT;

View file

@ -1,39 +0,0 @@
-- Roadmap status enum gains a fifth value `planned` for items that are
-- committed and scheduled but not yet started — sitting between `in_beta`
-- and `exploring` in the progression.
--
-- SQLite can't widen a CHECK constraint in place, so this is a full table
-- rebuild (same approach as 0006). roadmap_attributions has an ON DELETE
-- CASCADE FK to roadmap_items(id), so foreign keys are toggled off around
-- the rebuild to preserve attribution rows across the DROP/RENAME. The
-- metadata_text column added in 0007 is carried through.
PRAGMA foreign_keys = OFF;
CREATE TABLE roadmap_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring'
CHECK(status IN ('shipping','in_beta','planned','exploring','considering')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT,
metadata_text TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO roadmap_items_new
(id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at)
SELECT
id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at
FROM roadmap_items;
DROP TABLE roadmap_items;
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
PRAGMA foreign_keys = ON;

View file

@ -61,9 +61,6 @@ const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' }, { name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' }, { name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' }, { name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
]; ];
const insertUser = db.prepare(` const insertUser = db.prepare(`
@ -78,15 +75,7 @@ for (const c of newCabs) {
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up. // We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others. // Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all(); const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
'lars@virk2.dk': 24,
'anna@virk3.dk': 14,
'soren@virk4.dk': 12,
'henriette@virk5.dk': 10,
'mads@virk6.dk': 8,
'camilla@virk7.dk': 6,
'frederik@virk8.dk': 3,
};
const setCabMeta = db.prepare(` const setCabMeta = db.prepare(`
UPDATE users UPDATE users
@ -119,21 +108,6 @@ const cabMeta = {
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.', pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'], focus_tags: ['Legal', 'Policy', 'EU AI Act'],
}, },
'mads@virk6.dk': {
title: 'Chief Strategy Officer',
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
focus_tags: ['Healthcare', 'Consent', 'Governance'],
},
'camilla@virk7.dk': {
title: 'Head of Cyber Resilience',
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
focus_tags: ['Defence', 'Resilience'],
},
'frederik@virk8.dk': {
title: 'Director of Public Innovation',
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
focus_tags: ['Public sector', 'Measurement'],
},
}; };
for (const u of cabRows) { for (const u of cabRows) {
@ -169,17 +143,14 @@ const nowIso = (offsetSeconds = 0) => {
return d.toISOString().replace('T', ' ').slice(0, 19); return d.toISOString().replace('T', ' ').slice(0, 19);
}; };
// ── Poll attached to a dispatch (the decision) — open, 2/4 voted ── // ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
// Polls are no longer standalone; they attach to a dispatch via pulse_id.
// We create the pulse first, capture its id, and stamp it on the dispatch
// when we INSERT it further down.
const pulseOptions = [ const pulseOptions = [
'Locking down on-prem deployment first', 'Locking down on-prem deployment first',
'Pushing the traceability layer to GA', 'Pushing the traceability layer to GA',
'Going wide on document ingestion', 'Going wide on document ingestion',
'Building the agentic query loop', 'Building the agentic query loop',
]; ];
const decisionPulseId = db.prepare(` const pulseId = db.prepare(`
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run( `).run(
@ -189,36 +160,27 @@ const decisionPulseId = db.prepare(`
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).lastInsertRowid; ).lastInsertRowid;
// 2 votes — Lars and Anna // 2 votes from cabs[0] and cabs[1]
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600)); .run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60)); .run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 9 items, status meaning 'currently live' rather than // ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
// 'shipping soon'. Items 1-2 are live in production; items 3-4 are in
// beta even if 'audit log export' has a near-term GA target. Travelled
// stop = (1 + 0.5) / 9 ≈ 0.17, putting the 'you are here' marker at
// the visible transition between travelled and ahead tones on the path.
const roadmap = [ const roadmap = [
{ title: 'Traceability layer', description: 'Every inference call writes a signed audit record. Shaped by Lars in our March session.', status: 'shipping', target: 'Live since March', display_order: 1, shipped_at: nowIso(-60 * 24 * 3600), attributed: [cabs[0].id], metadata_text: 'Shaped by Lars in our March session' }, { title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Document ingestion', description: "Indexing PDF, Word, and plain text with proper chunking. Pilot-tested with Mette's team.", status: 'shipping', target: 'Live since late May', display_order: 2, shipped_at: nowIso(-7 * 24 * 3600), attributed: [cabs[1].id, cabs[2].id], metadata_text: "Pilot-tested with Mette's team" }, { title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
{ title: 'Audit log export', description: 'Stream the signed records to your own S3 or on-prem object store.', status: 'in_beta', target: 'GA next week', display_order: 3, shipped_at: null, attributed: [cabs[3].id], metadata_text: 'Builds on traceability layer' }, { title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval over locked, on-prem document stores. Currently testing with two pilot organisations.', status: 'in_beta', target: 'July', display_order: 4, shipped_at: null, attributed: [cabs[1].id], metadata_text: 'Request beta access →' }, { title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
{ title: 'Contextual memory', description: 'Sessions that remember constraints between calls without leaking context across organisational boundaries.', status: 'exploring', target: 'Q3 2026', display_order: 5, shipped_at: null, attributed: [cabs[3].id], metadata_text: '2 council requests' },
{ title: 'Multi-organisation graphs', description: 'Permission-controlled knowledge spaces for departments within a single deployment.', status: 'exploring', target: 'Q3 2026', display_order: 6, shipped_at: null, attributed: [cabs[4].id], metadata_text: 'Open question on key custody' },
{ title: 'Multi-tenant isolation', description: 'Cryptographic separation between sub-organisations on shared infrastructure.', status: 'exploring', target: 'Q4 2026', display_order: 7, shipped_at: null, attributed: [cabs[5].id], metadata_text: null },
{ title: 'Federated learning hooks', description: 'Let aligned organisations train on shared signal without sharing the underlying data.', status: 'considering', target: '2027', display_order: 8, shipped_at: null, attributed: [], metadata_text: 'Council input wanted' },
{ title: 'Open evaluation framework', description: 'A public benchmark suite for compliant-AI use in regulated industries.', status: 'considering', target: '2027', display_order: 9, shipped_at: null, attributed: [], metadata_text: 'Long-term direction' },
]; ];
const insertRoad = db.prepare(` const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?)
`); `);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)'); const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) { for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid); const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid); for (const uid of r.attributed) insertAttr.run(id, uid);
} }
@ -247,9 +209,7 @@ for (const c of contribs) {
const dispatchSeed = [ const dispatchSeed = [
{ kind: 'decision', ageDays: 2, { kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3', title: 'We are deprioritising public-cloud parity for Q3',
excerpt: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3. excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction. So for Q3 the platform supports two deployment targets only on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki.`,
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3. body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction. The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
@ -289,17 +249,14 @@ It is not a blog. It is the studio talking to the room — short, dated, signed.
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all(); const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(` const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id) INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
VALUES (?,?,?,?,?,'published',?,?,?,?) VALUES (?,?,?,?,?,'published',?,?,?)
`); `);
for (let i = 0; i < dispatchSeed.length; i += 1) { for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i]; const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60); const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id; const authorId = fenjas[i % fenjas.length].id;
// Attach the decision-pulse to the decision dispatch — this is the demo insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
// case for polls-as-articles. Other dispatches stay poll-free.
const attachedPulse = d.kind === 'decision' ? decisionPulseId : null;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse);
} }
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past // ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
@ -372,13 +329,13 @@ const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at) INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?) VALUES (?,?,?,?,?)
`); `);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600)); insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600)); insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60)); insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`); console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)'); console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past'); console.log(' events: dinner + studio hours + working session, 2 past');

View file

@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
// schema's three statuses. In-progress items are actively being built and // schema's three statuses. In-progress items are actively being built and
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring. // tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
const SECTION_STATUS = { const SECTION_STATUS = {
'In progress': { status: 'in_beta', target: null }, 'In progress': { status: 'beta', target: null },
'Next': { status: 'exploring', target: 'Next quarter' }, 'Next': { status: 'exploring', target: 'Next quarter' },
'Later': { status: 'exploring', target: 'Later this year' }, 'Later': { status: 'exploring', target: 'Later this year' },
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,121 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,59 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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 ImageUploadField from './fields/ImageUploadField.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.kind === 'image-upload' && <ImageUploadField field={field} value={value} />}
{field.helperText && (
<p class="bs-helper">{field.helperText}</p>
)}
{error && (
<p class="bs-field-error" role="alert">{error}</p>
)}
</div>

View file

@ -1,112 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,318 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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 ────────────────────────────────────────
// Re-renders preview from the textarea's *current* value on every toggle.
// The server-side initial render is only valid for the seed value — once
// the admin types, only client-side rendering reflects what's there.
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', async () => {
const mode = tab.getAttribute('data-md-mode');
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
if (mode === 'preview') {
const value = input.value;
if (value.trim() === '') {
preview.innerHTML = '<p class="bs-md-empty">Nothing to preview yet.</p>';
} else {
const { marked } = await import('marked');
marked.setOptions({ breaks: true, gfm: true });
preview.innerHTML = await marked.parse(value);
}
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

@ -1,211 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,29 +0,0 @@
---
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

@ -1,30 +0,0 @@
---
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

@ -1,134 +0,0 @@
---
import type { ImageUploadField } from '../../resource-types';
interface Props {
field: ImageUploadField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
---
<div class="bs-imgup" data-imgup>
<!-- Stored value: the image URL. Set by upload, or pasteable directly. -->
<input
type="text"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
placeholder="Upload below, or paste an image URL"
maxlength={field.maxLength}
data-imgup-value
/>
<div class="bs-imgup-row">
<label class="bs-imgup-pick">
Choose image…
<input type="file" accept="image/png,image/jpeg" data-imgup-file hidden />
</label>
<button type="button" class="bs-imgup-clear" data-imgup-clear hidden>Remove</button>
<span class="bs-imgup-status" data-imgup-status aria-live="polite"></span>
</div>
<div class="bs-imgup-preview" data-imgup-preview hidden>
<img src={v} alt="" data-imgup-img />
</div>
</div>
<style>
.bs-imgup { display: flex; flex-direction: column; gap: var(--space-2); }
.bs-imgup-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.bs-imgup-pick {
display: inline-flex;
align-items: center;
font-size: var(--text-label-md);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-container);
color: var(--on-surface);
cursor: pointer;
}
.bs-imgup-pick:hover { background: var(--surface-container-high); }
.bs-imgup-clear {
background: none;
border: none;
color: var(--on-surface-muted);
font-size: var(--text-label-md);
cursor: pointer;
padding: var(--space-2);
}
.bs-imgup-clear:hover { color: var(--color-danger); }
.bs-imgup-status { font-size: var(--text-label-md); color: var(--on-surface-variant); }
.bs-imgup-status.is-error { color: var(--color-danger); }
.bs-imgup-preview {
border-radius: var(--radius-md);
overflow: hidden;
max-width: 280px;
background: var(--surface-container);
}
.bs-imgup-preview img { display: block; width: 100%; height: auto; }
</style>
<script>
document.querySelectorAll<HTMLElement>('[data-imgup]').forEach((root) => {
const valueInput = root.querySelector<HTMLInputElement>('[data-imgup-value]');
const fileInput = root.querySelector<HTMLInputElement>('[data-imgup-file]');
const clearBtn = root.querySelector<HTMLButtonElement>('[data-imgup-clear]');
const status = root.querySelector<HTMLElement>('[data-imgup-status]');
const preview = root.querySelector<HTMLElement>('[data-imgup-preview]');
const img = root.querySelector<HTMLImageElement>('[data-imgup-img]');
if (!valueInput || !fileInput) return;
function setStatus(msg: string, isError = false) {
if (!status) return;
status.textContent = msg;
status.classList.toggle('is-error', isError);
}
function syncPreview() {
const url = valueInput!.value.trim();
const has = url.length > 0;
if (img && has) img.src = url;
preview?.toggleAttribute('hidden', !has);
clearBtn?.toggleAttribute('hidden', !has);
}
syncPreview();
valueInput.addEventListener('input', syncPreview);
clearBtn?.addEventListener('click', () => {
valueInput.value = '';
fileInput.value = '';
setStatus('');
syncPreview();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg'].includes(file.type)) {
setStatus('Only PNG or JPG images are allowed.', true);
fileInput.value = '';
return;
}
setStatus('Uploading…');
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.url) {
setStatus(data.error ?? 'Upload failed.', true);
return;
}
valueInput.value = data.url;
setStatus('Uploaded ✓');
syncPreview();
} catch {
setStatus('Upload failed. Try again.', true);
}
});
});
</script>

View file

@ -1,41 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,47 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,53 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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

@ -1,24 +0,0 @@
---
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

@ -1,14 +0,0 @@
---
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

@ -1,25 +0,0 @@
---
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

@ -1,24 +0,0 @@
---
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

@ -1,23 +0,0 @@
---
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

@ -1,22 +0,0 @@
---
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

@ -1,93 +0,0 @@
---
/* ---------------------------------------------------------------------------
* 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 toDateOnly(v: string | null | undefined): string {
if (!v) return '';
const s = String(v);
const m = s.match(/^(\d{4}-\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 at the end of the chosen day.
</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">
<label class="bs-label" for="pulse_closes_at">Closes on</label>
<input
type="date"
id="pulse_closes_at"
name="pulse_closes_at"
class="bs-input"
value={toDateOnly(pulse?.closes_at)}
/>
<p class="bs-helper">
The pulse closes at the end of the chosen day. Opens automatically when the
dispatch is published.
</p>
</div>
</div>

View file

@ -1,323 +0,0 @@
/* ---------------------------------------------------------------------------
* 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;
}
/** Image upload uploads a png/jpg to /api/admin/upload and stores the
* returned URL as the field value (also pasteable as a plain URL). */
export interface ImageUploadField extends FieldBase {
kind: 'image-upload';
maxLength?: number;
}
export type Field =
| TextField
| TextareaField
| MarkdownField
| SelectField
| SelectAsyncField
| MultiSelectAsyncField
| MultiTextField
| DateField
| DatetimeField
| NumberField
| ReadonlyField
| ImageUploadField;
// ── 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 };
/** Request origin (e.g. "https://bifrost.fenja.ai") used to build absolute
* URLs in ActionResults like invite links. Always set by the route handler. */
origin: 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

@ -1,102 +0,0 @@
/* ---------------------------------------------------------------------------
* 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

@ -1,264 +0,0 @@
/* ---------------------------------------------------------------------------
* 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 ─────────────────────────────────────────────────────────────────
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', ' ');
}
/** "YYYY-MM-DD" → "YYYY-MM-DD 23:59:59" (end-of-day in DB local time). */
function endOfDaySqlite(dateStr: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return '';
return `${dateStr} 23:59:59`;
}
/** 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;
// Opens always = now (or the dispatch's publish moment). closes_at comes
// from a date input and snaps to 23:59:59 on the chosen day; missing →
// default to 14 days out.
const opens_at = nowSqlite();
const closesRaw = String(formData.get('pulse_closes_at') ?? '').trim();
const closes_at = endOfDaySqlite(closesRaw) || 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

@ -1,273 +0,0 @@
/* ---------------------------------------------------------------------------
* 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 { fmtDateTime } from '../../lib/markdown';
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' : '');
}
/** Compute a human duration label from start + end SQLite datetimes.
* Returns null when ends_at is missing (open-ended event). */
function computeDurationLabel(startsAt: string, endsAt: string | null): string | null {
if (!endsAt) return null;
const start = new Date(startsAt.replace(' ', 'T') + 'Z').getTime();
const end = new Date(endsAt.replace(' ', 'T') + 'Z').getTime();
const ms = end - start;
if (!Number.isFinite(ms) || ms <= 0) return null;
const minutes = Math.round(ms / 60_000);
if (minutes < 90) return `${minutes} min`;
const hours = ms / 3_600_000;
if (hours < 4) {
const rounded = Math.round(hours * 2) / 2; // nearest half hour
return Number.isInteger(rounded) ? `${rounded} hr` : `${rounded} hr`;
}
if (hours < 7) return 'Half day';
if (hours < 10) return 'Full day';
const days = Math.round(hours / 24);
return days === 1 ? '1 day' : `${days} days`;
}
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: 'Date',
width: '180px',
render: (item) => ({
title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
}),
},
{
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: '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: 'Event photo',
kind: 'image-upload',
maxLength: 400,
helperText: 'Optional png/jpg shown as the event photo on the hub. Upload a file or paste an image URL.',
},
{
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)));
const startsAt = toSqliteDatetime(String(data.starts_at));
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
return createEvent({
slug,
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: startsAt,
ends_at: endsAt,
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: computeDurationLabel(startsAt, endsAt),
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) => {
const startsAt = toSqliteDatetime(String(data.starts_at));
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
updateEvent(id, {
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: startsAt,
ends_at: endsAt,
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: computeDurationLabel(startsAt, endsAt),
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

@ -1,33 +0,0 @@
/* ---------------------------------------------------------------------------
* 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

@ -1,192 +0,0 @@
/* ---------------------------------------------------------------------------
* 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. ctx.origin comes from Astro.url.origin
// so the link is always absolute and clickable.
ctx.result = {
kind: 'invite-link',
url: `${ctx.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

@ -1,110 +0,0 @@
/* ---------------------------------------------------------------------------
* 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

@ -1,188 +0,0 @@
/* ---------------------------------------------------------------------------
* 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' },
planned: { label: 'Planned', class: 'pill-planned' },
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: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
{ 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: 'planned', label: 'Planned' },
{ 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

@ -1,340 +0,0 @@
/* ---------------------------------------------------------------------------
* 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,
updateUserEmail,
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',
required: true,
maxLength: 200,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
helperText: 'The members login identity. Normalised to lowercase on save; must be unique.',
},
{ 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`);
// Email (login identity) — only written when changed. Throws on a
// collision, which the save handler surfaces as a form error.
const newEmail = String(data.email ?? '').trim().toLowerCase();
if (newEmail && newEmail !== current.email) {
updateUserEmail(id, newEmail);
}
// 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 };

View file

@ -1,124 +0,0 @@
/* ---------------------------------------------------------------------------
* 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 'image-upload': {
if (typeof value !== 'string') return `${field.label} must be a URL`;
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
return `${field.label} must be ${field.maxLength} characters or fewer`;
}
return null;
}
case 'readonly':
return null;
}
}

View file

@ -1,370 +0,0 @@
---
import type { Event, UserPublic } from '../lib/db';
import { eventKindLabel } from '../lib/format';
interface Props {
event: Event | null;
attendees: UserPublic[]; // confirmed (status='yes') — kept for caller compat, not rendered here
confirmedCount: number;
myRsvp: 'yes' | 'no' | 'interested' | null;
greetingPrefix: string; // e.g. "Good afternoon, "
firstName: string;
memberLabel?: string | null; // e.g. "MEMBER · 001"
}
const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props;
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
}
const dayPadded = event ? String(parseUtc(event.starts_at).getUTCDate()).padStart(2, '0') : '';
const weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
const monthShort = event ? fmt({ month: 'short' }, event.starts_at).toUpperCase() : '';
const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, event.starts_at) : '';
---
<article
class:list={["hero", { "hero--empty": !event }]}
aria-label={event ? `Next gathering: ${event.title}` : 'Next gathering'}
>
{event?.photo_url && (
<div class="hero-photo" aria-hidden="true">
<img src={event.photo_url} alt="" loading="lazy" />
</div>
)}
<!-- Greeting now lives inside the box, top of the card. -->
<div class="hero-greeting">
<h1 class="hero-greeting-line">{greetingPrefix}<em>{firstName}</em>.</h1>
{memberLabel && (
<div class="hero-greeting-meta">
<span class="hero-greeting-member">{memberLabel}</span>
<span class="hero-greeting-circle">Founding circle</span>
</div>
)}
</div>
{event ? (
<>
<div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe
the next event. -->
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
<span class="hero-day">{dayPadded}</span>
<span class="hero-month-time">{monthShort} · {startTime}</span>
</div>
<div class="hero-mid">
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
</div>
</div>
<footer class="hero-foot">
<p class="hero-status">
{event.capacity ? `${event.capacity} SEATS · ` : ''}{confirmedCount} CONFIRMED
</p>
<form method="POST" class="hero-actions">
<input type="hidden" name="action" value="rsvp" />
<input type="hidden" name="event_slug" value={event.slug} />
{myRsvp === 'yes' ? (
<>
<span class="hero-confirmed">You're confirmed ✓</span>
<button type="submit" name="status" value="no" class="hero-change">Change</button>
</>
) : (
<>
<button type="submit" name="status" value="no" class="hero-decline">Can't make it</button>
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
</>
)}
</form>
</footer>
</>
) : (
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
)}
</article>
<style>
.hero {
position: relative;
overflow: hidden;
background: var(--ink);
color: var(--on-ink);
border-radius: 14px;
padding: 32px 36px 30px;
display: flex;
flex-direction: column;
gap: 44px; /* generous space between greeting and the event */
min-height: 480px; /* much taller — gives the photo room to breathe */
}
/* Event block: label stacked above the date + title grid. */
.hero-event {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Greeting (inside the box, top) ──────────────────────────────── */
.hero-greeting {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.hero-greeting-line {
font-family: var(--font-serif);
font-weight: 400;
font-size: 34px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-ink);
margin: 0;
}
.hero-greeting-line em { font-style: italic; }
.hero-greeting-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
text-align: right;
}
.hero-greeting-member,
.hero-greeting-circle {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
.hero-top {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 140px 1fr;
gap: 32px;
align-items: start;
}
/* ── Date column ─────────────────────────────────────────────── */
.hero-date {
display: flex;
flex-direction: column;
gap: 6px;
}
.hero-weekday {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
.hero-day {
font-family: var(--font-serif);
font-weight: 400;
font-size: 88px;
line-height: 0.85;
color: var(--on-ink);
}
.hero-month-time {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
}
/* ── Mid column ──────────────────────────────────────────────── */
.hero-mid {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 26px;
line-height: 1.15;
color: var(--on-ink);
margin: 0;
}
.hero-desc {
font-size: 14px;
line-height: 1.55;
color: var(--on-ink-body);
margin: 0;
max-width: 380px;
}
.hero-location {
font-size: 13px;
color: var(--on-ink-muted);
margin: 0;
}
/* ── Photo as card background ──────────────────────────────────
The image fills the whole card behind the content. A multi-stop
--ink gradient keeps the left (text) side solid and lets the photo
surface on the right, fading back into the box at the bottom so the
footer stays legible. --ink is rgb(44,58,82). */
.hero-photo {
position: absolute;
top: 0;
right: 0;
width: 70%; /* 30% smaller than the full card */
height: 70%;
z-index: 0;
}
.hero-photo img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.hero-photo::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
/* Blend the inner edges (left + bottom) into the box; a light veil at
the top keeps the greeting meta legible where it overlaps. */
background:
linear-gradient(to left, rgba(44, 58, 82, 0) 38%, var(--ink) 100%),
linear-gradient(to bottom, rgba(44, 58, 82, 0) 48%, var(--ink) 100%),
linear-gradient(to top, rgba(44, 58, 82, 0) 78%, rgba(44, 58, 82, 0.45) 100%);
}
/* ── Bottom strip ────────────────────────────────────────────── */
.hero-foot {
position: relative;
z-index: 1;
margin-top: auto; /* pin to the bottom of the taller card */
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.hero-status {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
margin: 0;
}
.hero-actions {
display: flex;
align-items: center;
gap: 16px;
}
.hero-decline {
background: none;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
padding: 0;
}
.hero-decline:hover { color: var(--on-ink); }
.hero-cta {
background: var(--on-ink);
color: var(--ink);
border: none;
padding: 9px 22px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.hero-cta:hover { opacity: 0.88; }
.hero-confirmed {
color: var(--on-ink);
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 9px 22px;
border: 0.5px solid rgba(255, 252, 247, 0.3);
border-radius: 999px;
}
.hero-change {
background: transparent;
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
text-decoration: underline;
}
.hero-change:hover { color: var(--on-ink); }
/* ── Empty state ─────────────────────────────────────────────── */
.hero--empty {
min-height: 320px;
}
.hero-empty {
font-family: var(--font-serif);
font-size: 20px;
color: var(--on-ink-body);
margin: 0;
max-width: 32rem;
}
.hero-empty em { font-style: italic; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.hero-top { grid-template-columns: 1fr; }
}
/* Phone tuning: lighter padding, smaller display date, full-width copy. */
@media (max-width: 480px) {
.hero { padding: 24px 22px 22px; gap: 22px; min-height: 440px; }
.hero-greeting-line { font-size: 27px; }
.hero-day { font-size: 64px; }
.hero-title { font-size: 22px; }
.hero-desc { max-width: none; }
.hero-foot { gap: 12px; }
}
</style>

View file

@ -1,208 +0,0 @@
---
import { getLatestPublishedDispatches } from '../lib/db';
import {
dispatchSlug, dispatchKindLabel, splitExcerpt, relativeTime,
} from '../lib/format';
const [latest] = getLatestPublishedDispatches(1);
const [p1, p2] = latest
? splitExcerpt(latest.excerpt || latest.body)
: ['', null];
// Mark p2 with an ellipsis when the source extends beyond what we used —
// i.e. the body is longer than excerpt + paragraph break.
const sourceLen = latest ? (latest.excerpt || latest.body).trim().length : 0;
const usedLen = p1.length + (p2 ? p2.length + 2 : 0);
const truncated = sourceLen > usedLen + 4;
const authorFirstName = latest ? latest.author_name.split(' ')[0] : '';
const authorInitial = authorFirstName ? authorFirstName[0].toUpperCase() : '';
const authorRole = latest?.author_title ?? 'team';
---
{latest && (
<div class="rr-dispatch">
<div class="rr-dispatch-meta">
<div class="rr-dispatch-meta-left">
<span class="rr-dispatch-eyebrow">
Latest dispatch · {relativeTime(latest.published_at ?? latest.created_at)}
</span>
<span class:list={['rr-dispatch-kind', `rr-dispatch-kind-${latest.kind}`]}>
{dispatchKindLabel(latest.kind)}
</span>
</div>
<a class="rr-dispatch-all" href="/dispatches">All dispatches →</a>
</div>
<h2 class="rr-dispatch-title">{latest.title}</h2>
<div class="rr-dispatch-body">
<div class="rr-dispatch-text">
<p class="rr-dispatch-p1">{p1}</p>
{p2 && (
<p class="rr-dispatch-p2">{p2}{truncated ? '…' : ''}</p>
)}
</div>
<div class="rr-dispatch-author">
<div class="rr-dispatch-author-row">
<div class="rr-dispatch-author-text">
<p class="rr-dispatch-author-name">{authorFirstName}</p>
<p class="rr-dispatch-author-role">{authorRole}</p>
</div>
<div class="rr-dispatch-author-avatar">{authorInitial}</div>
</div>
<a class="rr-dispatch-cta" href={`/dispatches/${dispatchSlug(latest)}`}>
Read full dispatch →
</a>
</div>
</div>
</div>
)}
<style>
.rr-dispatch {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: 14px;
padding: 36px 40px;
}
.rr-dispatch-meta {
display: grid;
grid-template-columns: 1fr auto;
gap: 24px;
align-items: baseline;
margin-bottom: 22px;
}
.rr-dispatch-meta-left {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.rr-dispatch-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.rr-dispatch-kind {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 0.8px;
padding: 2px 8px;
border-radius: 3px;
text-transform: uppercase;
font-weight: 500;
}
.rr-dispatch-kind-decision { background: rgba(44,58,82,0.10); color: #2c3a52; }
.rr-dispatch-kind-update { background: rgba(109,140,124,0.12);color: #6d8c7c; }
.rr-dispatch-kind-behind_the_scenes { background: rgba(120,95,83,0.12); color: #785f53; }
.rr-dispatch-kind-note { background: rgba(185,107,88,0.10); color: #b96b58; }
.rr-dispatch-all {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 1px;
color: var(--on-surface-variant);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
}
.rr-dispatch-all:hover { color: var(--on-surface); border-bottom: none; }
.rr-dispatch-title {
font-family: var(--font-serif);
font-size: 30px;
line-height: 1.2;
color: var(--on-surface);
margin: 0 0 22px;
max-width: 720px;
}
.rr-dispatch-body {
display: grid;
grid-template-columns: 1fr auto;
gap: 40px;
align-items: end;
}
.rr-dispatch-text { max-width: 720px; }
.rr-dispatch-p1 {
font-size: 15px;
line-height: 1.7;
color: var(--on-surface);
margin: 0 0 10px;
}
.rr-dispatch-p2 {
font-size: 15px;
line-height: 1.7;
color: var(--on-surface-variant);
margin: 0;
}
.rr-dispatch-author {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14px;
padding-bottom: 4px;
}
.rr-dispatch-author-row {
display: flex;
align-items: center;
gap: 10px;
}
.rr-dispatch-author-text { text-align: right; }
.rr-dispatch-author-name {
font-size: 14px;
margin: 0;
color: var(--on-surface);
}
.rr-dispatch-author-role {
font-size: 12px;
margin: 1px 0 0;
color: var(--on-surface-variant);
}
.rr-dispatch-author-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ink);
color: #fffcf7;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-serif);
font-style: italic;
font-size: 15px;
flex-shrink: 0;
}
.rr-dispatch-cta {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 1.2px;
color: var(--pigment-terracotta);
text-transform: uppercase;
text-decoration: none;
padding-bottom: 2px;
border-bottom: 1px solid var(--pigment-terracotta);
white-space: nowrap;
}
.rr-dispatch-cta:hover { opacity: 0.78; }
@media (max-width: 767px) {
.rr-dispatch { padding: 28px 24px; }
.rr-dispatch-title { font-size: 24px; }
.rr-dispatch-body { grid-template-columns: 1fr; gap: 22px; }
.rr-dispatch-author {
flex-direction: row;
justify-content: space-between;
align-items: center;
order: -1;
}
}
</style>

View file

@ -51,7 +51,7 @@ const tags = readFocusTags(member.focus_tags);
<style> <style>
.m-card { .m-card {
background: var(--ink); background: var(--ink);
color: var(--on-ink); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
display: flex; display: flex;
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
background: var(--on-ink); background: var(--ink-text);
color: var(--ink); color: var(--ink);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -78,7 +78,7 @@ const tags = readFocusTags(member.focus_tags);
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic; font-style: italic;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 13px;
line-height: 1; line-height: 1;
flex-shrink: 0; flex-shrink: 0;
} }
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 500; font-weight: 500;
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.m-name { .m-name {
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 400; font-weight: 400;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.15; line-height: 1.15;
color: var(--on-ink); color: var(--ink-text);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.m-since-value { .m-since-value {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--on-ink); color: var(--ink-text);
} }
.m-tags { .m-tags {
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
gap: 6px; gap: 6px;
} }
.m-tag { .m-tag {
border: 0.5px solid rgba(255, 252, 247, 0.3); border: 0.5px solid rgba(232, 224, 208, 0.3);
color: var(--on-ink); color: var(--ink-text);
padding: 3px 8px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
font-family: var(--font-sans); font-family: var(--font-sans);

View file

@ -1,264 +0,0 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
}
const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
planned: 'var(--pigment-indigo)',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
planned: 'var(--pigment-indigo)',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
/** First-names-only attribution string. Empty when no attribution exists. */
function attributionLine(attributed: { name: string }[]): string {
if (!attributed.length) return '';
const names = attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}.`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}.`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}.`;
}
const hasArrows = items.length > 3;
---
<section class="roadmap-section" aria-label="On the roadmap">
<header class="roadmap-header">
<h2 class="roadmap-title">On the <em>roadmap.</em></h2>
<div class="roadmap-actions">
<a href="/roadmap" class="roadmap-all">See the full roadmap →</a>
{hasArrows && (
<div class="roadmap-arrows" role="group" aria-label="Scroll controls">
<button type="button" class="roadmap-arrow" data-dir="prev" aria-label="Previous" disabled>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M9 2 L4 7 L9 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button type="button" class="roadmap-arrow" data-dir="next" aria-label="Next">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5 2 L10 7 L5 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
)}
</div>
</header>
<div class="carousel-wrap">
<div class="carousel-scroll" data-carousel-scroll>
<div class="carousel-strip">
{items.map(item => (
<article class="carousel-card">
<header class="card-status">
<span class="card-dot" style={`background:${STATUS_DOT_COLOR[item.status]}`} aria-hidden="true"></span>
<span class="card-status-label" style={`color:${STATUS_LABEL_COLOR[item.status]}`}>
{STATUS_LABEL[item.status]}{item.target ? ` · ${item.target.toUpperCase()}` : ''}
</span>
</header>
<h3 class="card-title">{item.title}</h3>
<p class="card-desc">
{item.description}
{item.attributed.length > 0 && (
<span class="card-attribution"> {attributionLine(item.attributed)}</span>
)}
</p>
</article>
))}
</div>
</div>
{hasArrows && <div class="carousel-fade-right" data-carousel-fade></div>}
</div>
</section>
<script>
// Vanilla carousel — scroll by card width, update arrow disabled state,
// fade the right gradient when scrolled to the end.
document.querySelectorAll<HTMLElement>('.roadmap-section').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('[data-carousel-scroll]');
const fade = section.querySelector<HTMLElement>('[data-carousel-fade]');
if (!scroll) return;
const prev = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="prev"]');
const next = section.querySelector<HTMLButtonElement>('.roadmap-arrow[data-dir="next"]');
function cardWidth() {
const card = scroll!.querySelector<HTMLElement>('.carousel-card');
return card ? card.getBoundingClientRect().width : scroll!.clientWidth;
}
function update() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 1;
const atEnd = scroll!.scrollLeft >= max - 1;
if (prev) prev.disabled = atStart;
if (next) next.disabled = atEnd;
if (fade) fade.style.opacity = atEnd ? '0' : '1';
}
prev?.addEventListener('click', () => scroll.scrollBy({ left: -cardWidth(), behavior: 'smooth' }));
next?.addEventListener('click', () => scroll.scrollBy({ left: cardWidth(), behavior: 'smooth' }));
scroll.addEventListener('scroll', update, { passive: true });
update();
});
</script>
<style>
.roadmap-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.roadmap-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 22px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.roadmap-title em { font-style: italic; }
.roadmap-actions {
display: flex;
align-items: baseline;
gap: 18px;
}
.roadmap-all {
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.roadmap-all:hover { opacity: 0.8; border-bottom: none; }
.roadmap-arrows {
display: flex;
gap: 8px;
align-self: center;
}
.roadmap-arrow {
width: 30px;
height: 30px;
border-radius: 50%;
border: 0.5px solid rgba(0, 0, 0, 0.15);
background: var(--background);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: opacity var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.roadmap-arrow:hover:not(:disabled) { background: var(--surface-container-low); }
.roadmap-arrow:disabled { opacity: 0.25; cursor: default; }
.carousel-wrap { position: relative; }
.carousel-scroll {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
}
.carousel-scroll::-webkit-scrollbar { display: none; }
.carousel-strip {
display: flex;
}
.carousel-card {
flex: 0 0 calc((100% - 2px) / 3);
scroll-snap-align: start;
background: var(--background);
padding: 24px 26px;
box-sizing: border-box;
border-right: 0.5px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 12px;
min-height: 168px;
}
.carousel-card:last-child { border-right: none; }
.carousel-fade-right {
position: absolute;
right: 0; top: 0; bottom: 0;
width: 80px;
background: linear-gradient(to right, transparent, var(--background));
pointer-events: none;
transition: opacity 0.2s ease;
}
/* Card contents */
.card-status {
display: flex;
align-items: center;
gap: 8px;
}
.card-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.card-status-label {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
}
.card-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 19px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.card-desc {
font-size: 13px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.card-attribution {
color: var(--on-surface-muted);
}
/* Mobile: one card per view */
@media (max-width: 768px) {
.carousel-card { flex: 0 0 88%; }
.roadmap-arrows { display: none; }
.carousel-fade-right { display: none; }
}
</style>

View file

@ -1,778 +0,0 @@
---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
interface Props {
items: RoadmapItemWithAttribution[];
viewportWidth?: number; // SSR fallback for the layout math
}
const { items, viewportWidth = 1100 } = Astro.props;
// Align the first milestone with the left edge of the page's content column
// (matches the LatestDispatchBanner below). --content-max is 72rem = 1152px.
const CONTENT_MAX = 1152;
const DEFAULT_PADDING = 60;
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
// Trailing room past the last milestone = a quarter of the viewport, so the
// final item can scroll until it sits halfway between the right edge and the
// screen centre (~0.75 of the viewport). The drawn line extends the same
// distance so it keeps going as that item arrives. (Client recompute redoes
// this with the real viewport; this is the SSR fallback.)
const trailing = Math.round(viewportWidth * 0.25);
const layout = computeRouteLayout({
itemCount: items.length,
viewportWidth,
paddingLeft,
paddingRight: trailing,
tailLength: trailing,
});
const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
planned: '#5a6d83',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
planned: '#5a6d83',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
// "You are here" — the most recent shipping item. -1 if nothing has shipped yet.
let lastShippingIndex = -1;
items.forEach((it, i) => { if (it.status === 'shipping') lastShippingIndex = i; });
function trailingLine(item: RoadmapItemWithAttribution): string | null {
if (item.metadata_text && item.metadata_text.trim().length > 0) return item.metadata_text;
if (item.attributed.length > 0) {
const names = item.attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
}
return null;
}
// Stringified x position of the 'you are here' milestone for the
// initial-scroll logic in the nav script. -1 → 0 (no scroll offset).
const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex] : 0;
---
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}>
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
.page max-width so the route can span the actual viewport while
the header above and legend below stay centred in the content
column. -->
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
<div class="rr-scroll" id="rr-scroll">
<div class="rr-scroll-inner">
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" aria-hidden="true">
<defs>
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(travelledStop)} stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(Math.min(1, travelledStop + 0.06))} stop-color="#2a2520" stop-opacity="0.15"/>
<stop offset="1" stop-color="#2a2520" stop-opacity="0.15"/>
</linearGradient>
</defs>
{layout.pathD && (
<path id="rr-path-d" d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
)}
</svg>
{items.map((item, i) => (
<div
class="rr-milestone"
data-y={layout.itemY[i]}
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
>
<div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
style={`background:${STATUS_DOT_COLOR[item.status]};`}
aria-hidden="true"
></div>
<div class:list={['rr-attach', `rr-attach-${layout.cardSide[i]}`]}>
<div class="rr-connector" aria-hidden="true"></div>
<a class="rr-card" tabindex="0" href={`#item-${item.id}`} id={`item-${item.id}`}>
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rr-card-title">{item.title}</p>
<div class="rr-more">
{item.description && <p class="rr-desc">{item.description}</p>}
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
</div>
</a>
</div>
</div>
))}
</div>
</div>
</div>
<div class="rr-fade-left" id="rr-fade-l" aria-hidden="true"></div>
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
<!-- Single forward-only advance affordance anchored to the right
viewport edge. There's no left arrow on purpose — the path
reads left-to-right and the user's instinct after looking at
a milestone is 'what's next?', not 'what came before?'. -->
<button
type="button"
class="rr-advance"
id="rr-advance"
aria-label="Further along the route"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- Legend lives in /roadmap.astro now so it returns to centred
content-column width below the full-bleed route. -->
<!-- Mobile vertical timeline -->
<ol class="rr-mobile" aria-label="Roadmap timeline">
{items.map((item, i) => (
<li class="rrm-row">
<div class="rrm-track-col" aria-hidden="true">
<span class="rrm-dot" style={`background:${STATUS_DOT_COLOR[item.status]};`}></span>
{i < items.length - 1 && <span class="rrm-line"></span>}
</div>
<div class="rrm-body">
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rrm-title">{item.title}</p>
{item.description && <p class="rrm-desc">{item.description}</p>}
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
</div>
</li>
))}
</ol>
</section>
<script>
// Vanilla route runtime. Two concerns:
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
// on the client we know the real viewport, so we recompute itemX
// positions + SVG path d + track width on mount and on (debounced)
// resize. itemY values come from data-y on each milestone (path
// amplitude doesn't change with viewport, only the horizontal spread).
const MIN_SPACING = 320;
const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
const MID_Y = 210; // vertical centreline = track height (420) / 2
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
const wrap = section.querySelector<HTMLElement>('.rr-wrap');
const track = section.querySelector<HTMLElement>('#rr-track');
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
const advance = section.querySelector<HTMLButtonElement>('#rr-advance');
if (!scroll || !track || !svg) return;
const itemCount = milestones.length;
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
// Current horizontal positions, kept in sync by recompute() — used by
// the scroll-proximity focus effect.
let itemXs: number[] = [];
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
function recompute() {
const vw = window.innerWidth;
const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
// Match the SSR offset — first item aligns with the content-column
// left edge so the route lines up with the dispatch banner below.
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
// Trailing room = a quarter of the viewport so the final milestone can
// scroll until it sits halfway to the screen centre (~0.75 of vw).
const trailing = Math.round(vw * 0.25);
const trackWidth = paddingLeft + usableWidth + trailing;
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
itemX.push(
itemCount === 1
? paddingLeft + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
);
}
// Bezier path: control points at the segment midpoint x with control
// y values matching the prior and next milestone (keeps the tangent
// flat at each dot — the "river" feel from the layout helper).
let d = '';
if (itemCount > 0) {
d = `M ${itemX[0]} ${itemY[0]}`;
for (let i = 1; i < itemCount; i += 1) {
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the line past the last milestone, easing
// back to the centreline so it keeps going as that item scrolls in.
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + trailing;
const tcx = (lastX + tailEndX) / 2;
d += ` C ${tcx} ${lastY}, ${tcx} ${MID_Y}, ${tailEndX} ${MID_Y}`;
}
// Apply.
track!.style.width = `${trackWidth}px`;
svg!.setAttribute('width', String(trackWidth));
if (pathD && d) pathD.setAttribute('d', d);
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
itemXs = itemX;
}
/* Scroll-proximity focus: emphasise the milestone nearest the centre
of the viewport and let those toward the edges recede + dim. Driven
every frame that the track moves (via updateNav), so movement feels
alive rather than a flat pan. Not parallax — every milestone still
tracks the scroll 1:1; only scale + opacity shift with position. */
function updateFocus() {
if (!scroll || itemXs.length === 0) return;
const center = scroll.scrollLeft + scroll.clientWidth / 2;
const half = Math.max(1, scroll.clientWidth / 2);
milestones.forEach((m, i) => {
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
const scale = (1 - 0.10 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
});
}
/* Edge state — fades + advance disable. */
function updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 2;
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (advance) advance.classList.toggle('rr-at-end', atEnd);
updateFocus();
}
/* ── Unified scroll handling: wheel, drag, animated glide. ──
No CSS scroll-snap and no scroll-behavior: smooth — both fight
the JS-driven smooth motion. Drag has momentum; wheel translates
vertical to horizontal; arrow click runs a cubic-ease animation. */
let isDragging = false;
let dragStartX = 0;
let dragStartScrollLeft = 0;
let dragTotalMovement = 0;
let lastMoveX = 0;
let lastMoveTime = 0;
let velocity = 0; // px/ms, signed (positive = pointer moving right)
let momentumRAF: number | null = null;
let animateRAF: number | null = null;
let wheelRAF: number | null = null;
let wheelTarget = 0; // eased target scrollLeft for wheel/trackpad input
function cancelAnims() {
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
if (wheelRAF !== null) { cancelAnimationFrame(wheelRAF); wheelRAF = null; }
}
function animateScrollTo(target: number, durationMs: number) {
cancelAnims();
const start = scroll!.scrollLeft;
const delta = target - start;
const startTime = performance.now();
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const step = () => {
const t = Math.min(1, (performance.now() - startTime) / durationMs);
scroll!.scrollLeft = start + delta * easeOut(t);
updateNav();
if (t < 1) animateRAF = requestAnimationFrame(step);
else animateRAF = null;
};
animateRAF = requestAnimationFrame(step);
}
// Wheel — vertical wheel becomes horizontal scroll on this element.
// Trackpads sending horizontal deltaX go through unchanged (1:1, no scaling).
scroll.addEventListener('wheel', (e) => {
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (dx === 0) return;
e.preventDefault();
// Drop any drag-momentum or arrow glide that's mid-flight, but keep
// building onto the wheel target so quick successive ticks accumulate
// distance and the glide stays continuous.
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
const max = scroll!.scrollWidth - scroll!.clientWidth;
const base = wheelRAF !== null ? wheelTarget : scroll!.scrollLeft;
wheelTarget = Math.max(0, Math.min(max, base + dx));
// Ease scrollLeft toward the target each frame (~0.2 of the remaining
// distance), so the wheel feels like a smooth glide rather than a jump.
if (wheelRAF === null) {
const step = () => {
const diff = wheelTarget - scroll!.scrollLeft;
if (Math.abs(diff) < 0.5) {
scroll!.scrollLeft = wheelTarget;
wheelRAF = null;
updateNav();
return;
}
scroll!.scrollLeft += diff * 0.2;
updateNav();
wheelRAF = requestAnimationFrame(step);
};
wheelRAF = requestAnimationFrame(step);
}
}, { passive: false });
// Drag — pointer events; momentum on release.
scroll.addEventListener('pointerdown', (e) => {
if (e.button !== undefined && e.button !== 0) return;
// Don't start a drag when the click target is the advance button.
if (advance && advance.contains(e.target as Node)) return;
isDragging = true;
dragStartX = e.pageX;
dragStartScrollLeft = scroll!.scrollLeft;
dragTotalMovement = 0;
lastMoveX = e.pageX;
lastMoveTime = performance.now();
velocity = 0;
cancelAnims();
try { scroll!.setPointerCapture(e.pointerId); } catch { /* not all envs */ }
scroll!.classList.add('rr-dragging');
});
scroll.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const dx = e.pageX - dragStartX;
scroll!.scrollLeft = dragStartScrollLeft - dx;
dragTotalMovement = Math.max(dragTotalMovement, Math.abs(dx));
const now = performance.now();
const dt = now - lastMoveTime;
if (dt > 0) velocity = (e.pageX - lastMoveX) / dt;
lastMoveX = e.pageX;
lastMoveTime = now;
updateNav();
});
function endDrag() {
if (!isDragging) return;
isDragging = false;
scroll!.classList.remove('rr-dragging');
// Click vs drag: anything under 5px total movement is a click —
// skip momentum and let the underlying card's <a> handle the click.
if (dragTotalMovement < 5) return;
// Otherwise it's a real drag — suppress the synthetic click that
// follows so a drag-then-release-over-a-card doesn't navigate.
const suppressClick = (ev: Event) => {
ev.stopPropagation();
ev.preventDefault();
scroll!.removeEventListener('click', suppressClick, true);
};
scroll!.addEventListener('click', suppressClick, true);
// Momentum: signed velocity, decay 0.93 per frame, stop under 0.4 px/frame.
// Direction inverted because dragging right moves scrollLeft left.
let v = -velocity * 16;
const step = () => {
if (Math.abs(v) < 0.4) { momentumRAF = null; return; }
scroll!.scrollLeft += v;
v *= 0.93;
updateNav();
momentumRAF = requestAnimationFrame(step);
};
momentumRAF = requestAnimationFrame(step);
}
scroll.addEventListener('pointerup', endDrag);
scroll.addEventListener('pointercancel', endDrag);
// Advance arrow — animated glide of 60% viewport width.
advance?.addEventListener('click', () => {
const target = Math.min(
scroll!.scrollLeft + scroll!.clientWidth * 0.6,
scroll!.scrollWidth - scroll!.clientWidth,
);
animateScrollTo(target, 480);
});
scroll.addEventListener('scroll', updateNav, { passive: true });
// Debounced resize → recompute layout + refresh nav state. 120ms is
// long enough to coalesce drag-resize events without feeling laggy.
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
});
// Initial mount: recompute with the real viewport, then anchor at the
// start so the first milestone aligns with the content-column left edge.
// (The "you are here" highlight on the most-recent shipping milestone is
// still visible — but it's no longer the scroll anchor.)
recompute();
scroll.scrollLeft = 0;
setTimeout(updateNav, 50);
updateNav();
// Three-pulse hint on the advance arrow ~100ms after layout settles
// so the user notices the affordance once and then it sits quietly.
setTimeout(() => advance?.classList.add('rr-hint'), 100);
});
</script>
<style>
/* ── Desktop route ──────────────────────────────────────────────── */
.rr-wrap { position: relative; }
/* Escape the parent .page max-width so the route can use the actual
viewport width. The headline, dispatch banner, section header, and
legend all stay centred at content width — only the route widens. */
.rr-fullbleed {
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
}
.rr-scroll {
/* overflow-x: auto + overflow-y: visible lets hovered cards expand
above/below the track without being clipped. .rr-scroll-inner is
the spec-recommended belt-and-braces wrapper in case a browser
misbehaves on the combination.
NO scroll-snap-type and NO scroll-behavior: smooth — both fight
the JS drag-momentum + animated-glide implementation below. The
path is meant to glide continuously, not click into fixed
positions. */
overflow-x: auto;
overflow-y: visible;
scrollbar-width: none;
padding: 60px 80px 80px;
/* Drag affordance: cursor + suppress native horizontal swipe so
horizontal drag triggers our handler while vertical drag still
scrolls the page. user-select stops drag from selecting text. */
cursor: grab;
touch-action: pan-y;
user-select: none;
}
.rr-scroll::-webkit-scrollbar { display: none; }
.rr-scroll.rr-dragging { cursor: grabbing; }
/* Pointer-events off the cards mid-drag — prevents accidental hover
reveal while the track is being dragged past. */
.rr-scroll.rr-dragging .rr-card { pointer-events: none; }
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }
.rr-track { position: relative; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone {
position: absolute;
/* Inline transform/opacity are driven per-frame from JS based on each
milestone's distance from the viewport centre, so the track comes
alive as you move it (centre milestone emphasised, edges recede).
The short ease softens the per-frame updates into a glide. */
transform: translate(-50%, -50%);
transition: transform .2s ease-out, opacity .2s ease-out;
will-change: transform, opacity;
}
/* A hovered/focused card always reads at full size and brightness,
regardless of where it sits along the route — overrides the inline
focus styles JS sets. */
.rr-milestone:has(.rr-card:hover),
.rr-milestone:has(.rr-card:focus-visible) {
transform: translate(-50%, -50%) scale(1) !important;
opacity: 1 !important;
z-index: 10;
}
.rr-dot {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease;
}
.rr-dot.rr-current {
transform: scale(1.3);
box-shadow:
0 0 0 5px var(--background), /* cream halo */
0 0 0 6px rgba(185, 107, 88, 0.45); /* terracotta ring outside */
}
/* Hover-on-card animates the sibling dot too. :has() is fine on every
evergreen browser we target; older Firefox just doesn't grow the dot. */
.rr-milestone:has(.rr-card:hover) .rr-dot,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot {
transform: scale(1.15);
}
.rr-milestone:has(.rr-card:hover) .rr-dot.rr-current,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot.rr-current {
transform: scale(1.4);
}
.rr-attach {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.rr-attach-below { top: 7px; } /* hangs down from the dot */
.rr-attach-above { bottom: 7px; flex-direction: column-reverse; }
.rr-connector {
width: 1px;
height: 30px;
background: rgba(0, 0, 0, 0.18);
}
.rr-card {
display: block;
width: 240px;
padding: 14px 16px;
border-radius: 10px;
background: transparent;
color: inherit;
text-decoration: none;
border-bottom: none;
transition:
transform .35s cubic-bezier(.2,.7,.3,1),
box-shadow .35s ease,
background .25s ease;
cursor: pointer;
}
.rr-card:hover,
.rr-card:focus-visible {
background: var(--surface-card);
box-shadow:
0 12px 32px -16px rgba(42, 37, 32, 0.25),
0 0 0 0.5px var(--surface-card-border);
transform: translateY(-2px);
z-index: 10;
border-bottom: none;
outline: none;
}
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0 0 7px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 20px;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
}
.rr-more {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height .35s ease,
opacity .25s ease,
margin-top .35s ease;
margin-top: 0;
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 340px;
opacity: 1;
margin-top: 12px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.rr-trail {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
/* ── Advance arrow ─────────────────────────────────────────────── */
.rr-advance {
position: absolute;
right: 32px;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--pigment-terracotta);
background: var(--background);
color: var(--pigment-terracotta);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
z-index: 5;
transition: background .2s ease,
color .2s ease,
opacity .25s ease,
transform .25s ease;
}
.rr-advance:hover,
.rr-advance:focus-visible {
background: var(--pigment-terracotta);
color: var(--background);
outline: none;
transform: translateY(-50%) scale(1.06);
}
.rr-advance[disabled],
.rr-advance.rr-at-end {
opacity: 0.25;
pointer-events: none;
}
/* Three-pulse hint on first load — fires once, then stops. */
@keyframes rr-advance-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(185, 107, 88, 0); }
50% { box-shadow: 0 0 0 8px rgba(185, 107, 88, 0.15); }
}
.rr-advance.rr-hint {
animation: rr-advance-pulse 1.4s ease-in-out 3;
}
/* Edge fades cover only the track itself — the top/bottom padding
zones (60/80) on .rr-scroll exist so hover cards can overflow there
without clipping, so the fades shouldn't paint over them. */
.rr-fade-left, .rr-fade-right {
position: absolute;
top: 60px;
bottom: 80px;
pointer-events: none;
transition: opacity .25s ease;
}
.rr-fade-left {
left: 0; width: 60px;
background: linear-gradient(to left, transparent, var(--background));
opacity: 0;
}
.rr-fade-right {
right: 0; width: 90px;
background: linear-gradient(to right, transparent, var(--background));
}
/* ── Mobile vertical timeline ──────────────────────────────────── */
.rr-mobile { display: none; }
@media (max-width: 767px) {
.rr-desktop { display: none; }
.rr-mobile {
display: block;
list-style: none;
padding: 0;
margin: 0;
}
.rrm-row {
display: grid;
grid-template-columns: 32px 1fr;
gap: 16px;
align-items: start;
}
.rrm-track-col {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-height: 100%;
padding-top: 6px;
}
.rrm-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rrm-line {
width: 1px;
flex: 1;
min-height: 28px;
background: rgba(0, 0, 0, 0.18);
margin-top: 4px;
}
.rrm-body {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 28px;
}
.rrm-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0;
font-weight: 600;
}
.rrm-title {
font-family: var(--font-serif);
font-size: 21px;
line-height: 1.25;
color: var(--on-surface);
margin: 0;
}
.rrm-desc {
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.6;
color: var(--on-surface-variant);
margin: 0;
}
.rrm-trail {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
}
</style>

View file

@ -0,0 +1,44 @@
---
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

@ -0,0 +1,175 @@
---
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
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 — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
</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>
)}
<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; }
.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

@ -0,0 +1,218 @@
---
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

@ -0,0 +1,261 @@
---
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

@ -0,0 +1,179 @@
---
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', beta: 'Beta', exploring: 'Exploring' } 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' | 'beta' | 'exploring';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
beta: items.filter(i => i.status === '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),
};
---
<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="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="beta" selected={editing?.status === 'beta'}>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>
<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','beta','exploring'] 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; }
</style>

View file

@ -0,0 +1,90 @@
---
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

@ -15,7 +15,8 @@ const navLinks = [
]; ];
const footerLinks = [ const footerLinks = [
{ href: '/vision', label: 'Vision' }, { href: '/vision', label: 'Vision' },
{ href: '/council-manifesto', label: 'Council manifesto' },
]; ];
const currentPath = Astro.url.pathname; const currentPath = Astro.url.pathname;
@ -31,21 +32,7 @@ const year = new Date().getFullYear();
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span> <span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
</a> </a>
<button <nav class="nav-right" aria-label="Main navigation">
type="button"
class="nav-toggle"
id="nav-toggle"
aria-label="Menu"
aria-controls="nav-menu"
aria-expanded="false"
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="nav-toggle-bars" d="M3 6h18M3 12h18M3 18h18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path class="nav-toggle-x" d="M5 5l14 14M19 5L5 19" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
<nav class="nav-right" id="nav-menu" aria-label="Main navigation">
{navLinks.map(({ href, label }) => ( {navLinks.map(({ href, label }) => (
<a <a
href={href} href={href}
@ -125,52 +112,35 @@ const year = new Date().getFullYear();
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--space-3);
border-bottom: none; border-bottom: none;
color: var(--on-surface); color: var(--on-surface);
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
} }
.wordmark-link:hover { .wordmark-link:hover {
border-bottom: none; border-bottom: none;
color: var(--on-surface); color: var(--on-surface);
} }
.wordmark { .wordmark {
height: 30px; /* 50% larger than the prior 20px lockup */ height: 22px;
width: auto; width: auto;
display: block; display: block;
} }
.wordmark-sep { .wordmark-sep {
/* Flex-centred against the logo height so the dot sits on the vertical
middle of the "Fenja AI" logo. */
display: inline-flex;
align-items: center;
height: 30px;
color: var(--on-surface-muted); color: var(--on-surface-muted);
font-family: var(--font-serif); font-size: 1rem;
font-size: 22px;
line-height: 1;
}
/* Project (regular) + Bifrost (italic) share a baseline. Italic Newsreader
renders a touch taller at the same size, so Bifrost is set 1px smaller so
the two words read at the same cap height. inline-block + small symmetric
padding keeps the gradient-clip bbox from chopping the italic flourish. */
.wordmark-project,
.wordmark-bifrost {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
line-height: 1; line-height: 1;
} }
.wordmark-project { .wordmark-project {
font-size: 20px; font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 500;
color: var(--on-surface); color: var(--on-surface);
letter-spacing: 0;
} }
.wordmark-bifrost { .wordmark-bifrost {
display: inline-block; font-family: var(--font-serif);
font-size: 19px;
font-style: italic; font-style: italic;
padding: 2px 0; font-weight: 400;
vertical-align: baseline;
background-image: linear-gradient( background-image: linear-gradient(
90deg, 90deg,
var(--pigment-terracotta) 0%, var(--pigment-terracotta) 0%,
@ -184,23 +154,6 @@ const year = new Date().getFullYear();
color: transparent; color: transparent;
} }
/* ── Mobile menu toggle (hidden on desktop) ─────────────────────── */
.nav-toggle {
display: none;
margin-left: auto;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--on-surface);
border-radius: var(--radius-sm);
}
.nav-toggle .nav-toggle-x { display: none; }
/* ── Nav links ──────────────────────────────────────────────────── */ /* ── Nav links ──────────────────────────────────────────────────── */
.nav-right { .nav-right {
display: flex; display: flex;
@ -212,15 +165,14 @@ const year = new Date().getFullYear();
display: inline-block; display: inline-block;
width: 1px; width: 1px;
height: 18px; height: 18px;
background: rgba(0, 0, 0, 0.15); background: var(--ghost-border-color);
margin: 0 18px; margin: 0 var(--space-2);
transform: scaleX(0.5); transform: scaleX(0.5);
transform-origin: center; transform-origin: center;
} }
.nav-logout-form { display: inline-flex; } .nav-logout-form { display: inline-flex; }
.nav-link { .nav-link {
position: relative;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: 500; font-weight: 500;
@ -231,17 +183,17 @@ const year = new Date().getFullYear();
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border-bottom: none; border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard); transition: color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
} }
.nav-link:hover { .nav-link:hover {
color: var(--on-surface); color: var(--on-surface);
background: var(--surface-container-low);
border-bottom: none; border-bottom: none;
} }
/* Active nav link: terracotta + slightly heavier weight. Colour alone
is the indicator — no badge, bullet, italic, or family swap. */
.nav-link.active { .nav-link.active {
color: var(--pigment-terracotta); color: var(--on-surface);
font-weight: 500; background: var(--surface-container);
} }
/* ── User zone ──────────────────────────────────────────────────── */ /* ── User zone ──────────────────────────────────────────────────── */
@ -334,80 +286,4 @@ const year = new Date().getFullYear();
color: var(--on-surface-variant); color: var(--on-surface-variant);
border-bottom: none; border-bottom: none;
} }
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.nav-inner { padding: 0 var(--space-5); gap: var(--space-3); }
.nav-toggle { display: inline-flex; }
.nav.open .nav-toggle-bars { display: none; }
.nav.open .nav-toggle-x { display: inline; }
/* Right-hand nav becomes a full-width dropdown under the bar. */
.nav-right {
position: absolute;
top: 56px;
left: 0;
right: 0;
margin-left: 0;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: var(--space-2) var(--space-5) var(--space-4);
background: var(--glass-surface);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: var(--ghost-border);
box-shadow: var(--shadow-float);
display: none;
}
.nav.open .nav-right { display: flex; }
/* Comfortable tap targets in the dropdown. */
.nav-right .nav-link,
.nav-right .nav-user-name,
.nav-right .logout-btn {
display: flex;
align-items: center;
min-height: 44px;
width: 100%;
padding: var(--space-2) var(--space-2);
font-size: var(--text-body-md);
border-radius: var(--radius-sm);
}
.nav-divider {
width: auto;
height: 1px;
margin: var(--space-2) 0;
transform: scaleY(0.5);
}
.nav-logout-form { width: 100%; }
.logout-btn { justify-content: flex-start; }
/* Footer stacks. */
.footer-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: 0 var(--space-5);
}
}
</style> </style>
<script>
// Mobile nav: toggle the dropdown, keep aria in sync, close on link tap or
// when the viewport grows back to desktop.
const nav = document.querySelector<HTMLElement>('.nav');
const toggle = nav?.querySelector<HTMLButtonElement>('#nav-toggle');
const menu = nav?.querySelector<HTMLElement>('#nav-menu');
function setOpen(open: boolean) {
if (!nav || !toggle) return;
nav.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
toggle?.addEventListener('click', () => setOpen(!nav!.classList.contains('open')));
menu?.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => setOpen(false)));
window.addEventListener('resize', () => { if (window.innerWidth > 767) setOpen(false); });
</script>

View file

@ -152,15 +152,6 @@ export function updateUserProfile(id: number, name: string, bio: string): void {
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id); db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
} }
/** Update a user's email their login identity. Throws if the address is
* already used by another account (the column is UNIQUE). */
export function updateUserEmail(id: number, email: string): void {
const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?')
.get(email, id) as { id: number } | undefined;
if (clash) throw new Error('That email is already in use by another account.');
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
}
/** Returns the newly-allocated member_number when the transition lands on /** Returns the newly-allocated member_number when the transition lands on
* cab and the user had none; null otherwise. Callers may ignore. */ * cab and the user had none; null otherwise. Callers may ignore. */
export function updateUserRole(id: number, role: Role): { allocated: number | null } { export function updateUserRole(id: number, role: Role): { allocated: number | null } {
@ -222,8 +213,7 @@ export function updateUserAdminFields(id: number, data: {
export function slugifyName(name: string): string { export function slugifyName(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
.replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '');
} }
@ -314,15 +304,6 @@ 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: {
@ -476,20 +457,6 @@ 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. */
@ -667,21 +634,6 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
).run(pulseId, userId, optionIndex); ).run(pulseId, userId, optionIndex);
} }
/** UPSERT first vote inserts, subsequent ones update option_index + voted_at.
* Use this when the UI allows members to change their pick while the pulse is
* still open. Returns true if this was a brand-new vote (so callers can
* record activity once), false if it changed an existing vote. */
export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean {
const existing = getUserVote(pulseId, userId);
db.prepare(`
INSERT INTO votes (pulse_id, user_id, option_index, voted_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(pulse_id, user_id) DO UPDATE
SET option_index = excluded.option_index, voted_at = excluded.voted_at
`).run(pulseId, userId, optionIndex);
return existing === null;
}
export function getUserVote(pulseId: number, userId: number): number | null { export function getUserVote(pulseId: number, userId: number): number | null {
const r = db.prepare( const r = db.prepare(
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
@ -698,7 +650,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ──────────────────────────────────────────────── // ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering'; export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
export interface RoadmapItem { export interface RoadmapItem {
id: number; id: number;
@ -708,7 +660,6 @@ export interface RoadmapItem {
target: string | null; target: string | null;
display_order: number; display_order: number;
shipped_at: string | null; shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -723,32 +674,20 @@ export function createRoadmapItem(data: {
status: RoadmapStatus; status: RoadmapStatus;
target?: string | null; target?: string | null;
display_order?: number; display_order?: number;
metadata_text?: string | null;
}): number { }): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const requestedOrder = data.display_order ?? 0; const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
return db.transaction(() => { VALUES (?,?,?,?,?,?)
// Cascade: insert at position N shifts every existing item at or after N `).run(
// down by one, keeping the order dense. data.title,
db.prepare( data.description,
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?' data.status,
).run(requestedOrder); data.target ?? null,
data.display_order ?? 0,
const r = db.prepare(` shipped_at,
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) );
VALUES (?,?,?,?,?,?,?) return Number(r.lastInsertRowid);
`).run(
data.title,
data.description,
data.status,
data.target ?? null,
requestedOrder,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
})();
} }
/** /**
@ -761,10 +700,9 @@ export function updateRoadmapItem(id: number, data: {
status: RoadmapStatus; status: RoadmapStatus;
target: string | null; target: string | null;
display_order: number; display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } { }): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?') const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined; .get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
if (!current) throw new Error(`Roadmap item ${id} not found`); if (!current) throw new Error(`Roadmap item ${id} not found`);
const shippedNow = data.status === 'shipping' && current.shipped_at === null; const shippedNow = data.status === 'shipping' && current.shipped_at === null;
@ -772,45 +710,18 @@ export function updateRoadmapItem(id: number, data: {
? new Date().toISOString().slice(0, 19).replace('T', ' ') ? new Date().toISOString().slice(0, 19).replace('T', ' ')
: current.shipped_at; : current.shipped_at;
return db.transaction(() => { db.prepare(`
// Cascade neighbours when display_order changes. UPDATE roadmap_items
// Moving forward (A → B, B > A): rows in (A, B] shift down by 1. SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
// Moving back (A → B, B < A): rows in [B, A) shift up by 1. shipped_at = ?, updated_at = datetime('now')
const from = current.display_order; WHERE id = ?
const to = data.display_order; `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
if (to > from) {
db.prepare(
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE id != ? AND display_order > ? AND display_order <= ?'
).run(id, from, to);
} else if (to < from) {
db.prepare(
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE id != ? AND display_order >= ? AND display_order < ?'
).run(id, to, from);
}
db.prepare(` return { shippedNow };
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return { shippedNow };
})();
} }
export function deleteRoadmapItem(id: number): void { export function deleteRoadmapItem(id: number): void {
db.transaction(() => { db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
const row = db.prepare('SELECT display_order FROM roadmap_items WHERE id = ?')
.get(id) as { display_order: number } | undefined;
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
if (row) {
// Cascade: every row after the deleted slot shifts up by 1.
db.prepare(
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE display_order > ?'
).run(row.display_order);
}
})();
} }
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null { export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
@ -1093,7 +1004,6 @@ export interface Dispatch {
published_at: string | null; published_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
pulse_id: number | null; // attached poll, if any
} }
export interface DispatchWithAuthor extends Dispatch { export interface DispatchWithAuthor extends Dispatch {
@ -1102,18 +1012,6 @@ export interface DispatchWithAuthor extends Dispatch {
author_role: Role; author_role: Role;
} }
export interface DispatchWithPoll extends DispatchWithAuthor {
poll: PulseWithCounts | null;
}
/** Optional poll attachment used when creating/updating a dispatch. */
export interface DispatchPollInput {
question: string;
options: string[];
opens_at: string;
closes_at: string;
}
export function createDispatch(data: { export function createDispatch(data: {
title: string; title: string;
body: string; body: string;
@ -1121,119 +1019,46 @@ export function createDispatch(data: {
kind: DispatchKind; kind: DispatchKind;
author_id: number; author_id: number;
status: DispatchStatus; status: DispatchStatus;
poll?: DispatchPollInput | null;
}): number { }): number {
const published_at = data.status === 'published' const published_at = data.status === 'published'
? new Date().toISOString().slice(0, 19).replace('T', ' ') ? new Date().toISOString().slice(0, 19).replace('T', ' ')
: null; : null;
return db.transaction(() => { const r = db.prepare(`
let pulseId: number | null = null; INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
if (data.poll && data.poll.options.length >= 2) { VALUES (?,?,?,?,?,?,?)
pulseId = createPulse({ `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
question: data.poll.question, return Number(r.lastInsertRowid);
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: data.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
const r = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
VALUES (?,?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
return Number(r.lastInsertRowid);
})();
} }
/** Update a dispatch and, optionally, manage its attached poll. */
export function updateDispatch(id: number, data: { export function updateDispatch(id: number, data: {
title: string; title: string;
body: string; body: string;
excerpt: string | null; excerpt: string | null;
kind: DispatchKind; kind: DispatchKind;
author_id: number; author_id: number;
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
}): void { }): void {
db.transaction(() => { db.prepare(`
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?') UPDATE dispatches
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined; SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
if (!cur) return; WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
let pulseId: number | null = cur.pulse_id;
if (data.pollExplicit) {
if (data.poll && data.poll.options.length >= 2) {
if (cur.pulse_id) {
// update the existing pulse in place
updatePulse(cur.pulse_id, {
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
});
} else {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: cur.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
} else {
// explicit detach
pulseId = null;
}
}
db.prepare(`
UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
pulse_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id);
})();
}
/** Dispatch + its attached poll (with counts + this viewer's vote). */
export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null {
const d = getDispatchById(dispatchId);
if (!d) return null;
const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null;
return { ...d, poll };
} }
/** Promote draft published, stamping published_at = now() on first publish. /** Promote draft published, stamping published_at = now() on first publish.
* Idempotent: if already published, published_at is preserved. Also opens * Idempotent: if already published, published_at is preserved. */
* any attached draft poll so members can start voting. */
export function publishDispatch(id: number): void { export function publishDispatch(id: number): void {
db.transaction(() => { db.prepare(`
db.prepare(` UPDATE dispatches
UPDATE dispatches SET status = 'published',
SET status = 'published', published_at = COALESCE(published_at, datetime('now')),
published_at = COALESCE(published_at, datetime('now')), updated_at = datetime('now')
updated_at = datetime('now') WHERE id = ?
WHERE id = ? `).run(id);
`).run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) publishPulse(row.pulse_id);
})();
} }
/** Archive a dispatch. Leaves published_at intact for history. Closes any /** Archive a dispatch. Leaves published_at intact for history. */
* attached open poll so the bar charts read final. */
export function archiveDispatch(id: number): void { export function archiveDispatch(id: number): void {
db.transaction(() => { db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) closePulse(row.pulse_id);
})();
} }
export function deleteDispatch(id: number): void { export function deleteDispatch(id: number): void {

Binary file not shown.

View file

@ -1,137 +0,0 @@
/**
* Coordinate-generation for the /roadmap horizontal route component.
*
* Given an itemCount + viewport width, produces:
* - itemX / itemY: position of each milestone dot on the SVG canvas
* - cardSide: which side ('below' or 'above') the milestone's card hangs on
* - pathD: a smooth cubic-bezier SVG path string snaking through all dots
* - trackWidth: total scroll width of the track ( viewportWidth so the
* page always offers visible content + scroll affordance)
* - midY: vertical centreline of the track, returned for callers that want
* to place additional decoration relative to the centre
*
* No DOM access here pure math. Tested directly in roadmap-layout.test.ts.
*/
export interface LayoutOpts {
itemCount: number;
viewportWidth: number;
minSpacingX?: number; // default 320
trackHeight?: number; // default 460
amplitude?: number; // default 120
paddingX?: number; // default 60 — symmetric leading + trailing padding
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
tailLength?: number; // px to extend the drawn path past the final
// milestone, easing back to the centreline — lets
// the line keep going as the last item scrolls in
}
export interface LayoutResult {
trackWidth: number;
pathD: string;
itemX: number[];
itemY: number[];
cardSide: ('above' | 'below')[];
midY: number;
}
export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const minSpacing = opts.minSpacingX ?? 320;
const trackHeight = opts.trackHeight ?? 420;
const amplitude = opts.amplitude ?? 120;
const paddingDef = opts.paddingX ?? 60;
const paddingL = opts.paddingLeft ?? paddingDef;
const paddingR = opts.paddingRight ?? paddingDef;
const midY = trackHeight / 2;
const itemCount = Math.max(0, opts.itemCount);
if (itemCount === 0) {
return {
trackWidth: opts.viewportWidth,
pathD: '',
itemX: [],
itemY: [],
cardSide: [],
midY,
};
}
// Aim for ~80% of viewport for low item counts; data-driven minimum
// takes over once items × minSpacing exceeds that target (the carousel
// case — track extends past viewport).
const targetUsableWidth = opts.viewportWidth * 0.80;
const dataDrivenWidth = (itemCount - 1) * minSpacing;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = paddingL + usableWidth + paddingR;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1
? paddingL + usableWidth / 2
: paddingL + (i / (itemCount - 1)) * usableWidth,
);
// First item on the centreline; subsequent items alternate up/down with
// a varying amplitude so the path feels hand-planned rather than purely
// sinusoidal. Multiplier ramps 0.78 (first off-axis) → ~1.18 (last item)
// — closer items swing less, further items swing more.
const denom = Math.max(1, itemCount - 1);
const itemY: number[] = itemX.map((_, i) => {
if (i === 0) return midY;
const direction = i % 2 === 1 ? -1 : 1;
const multiplier = 0.78 + (i / denom) * 0.4;
return midY + direction * amplitude * multiplier;
});
// Cards hang TOWARD the centreline rather than away from it. A dot above
// centre (odd index) gets a card below; a dot below centre (even index >0)
// gets a card above. This keeps every card growing into the track height
// rather than out the top or bottom of the scroll container — which the
// CSS spec clips regardless of overflow-y: visible (browser computes
// overflow-y to auto whenever overflow-x is auto). i=0 sits on the
// centreline, no clipping risk either way; defaulting to below.
const cardSide: ('above' | 'below')[] = itemX.map((_, i) => {
if (i === 0) return 'below';
return i % 2 === 1 ? 'below' : 'above';
});
// Smooth cubic-bezier path. Control points use the midpoint x of each
// segment, with control-y values matching the prior and next item
// respectively — this keeps the curve tangent flat at each milestone
// (the "river" feel rather than the "zigzag" feel).
let d = `M ${itemX[0]} ${itemY[0]}`;
for (let i = 1; i < itemCount; i += 1) {
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the path past the last milestone, easing it back
// to the centreline so the line keeps going while the final item scrolls
// toward the middle. Tangent stays flat at the last dot (control y = lastY).
if (opts.tailLength && opts.tailLength > 0) {
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + opts.tailLength;
const cx = (lastX + tailEndX) / 2;
d += ` C ${cx} ${lastY}, ${cx} ${midY}, ${tailEndX} ${midY}`;
}
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
}
/**
* The travelled-portion stop position on the path stroke gradient.
* - No shipping items: 0 (path is entirely "ahead" tone)
* - Some shipping items: (lastShippingIndex + 0.5) / itemCount
* - Clamped to [0, 0.98] so the fade-to-ahead is always visible
*/
export function travelledStopFor(
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering'>,
): number {
if (statuses.length === 0) return 0;
let last = -1;
statuses.forEach((s, i) => { if (s === 'shipping') last = i; });
if (last < 0) return 0;
return Math.min(0.98, (last + 0.5) / statuses.length);
}

View file

@ -1,35 +0,0 @@
/* ---------------------------------------------------------------------------
* Event photo uploads.
*
* Files are written outside the build output (so they survive redeploys) and
* served back through the /uploads/[file] route. Path defaults to
* <cwd>/data/uploads; override with BIFROST_UPLOAD_DIR on the VPS.
*
* Scope: admin-only event photos (see SPEC §8 exception). Participants cannot
* upload.
* ------------------------------------------------------------------------- */
import { join } from 'node:path';
export const UPLOAD_DIR =
process.env.BIFROST_UPLOAD_DIR ?? join(process.cwd(), 'data', 'uploads');
/** Accepted MIME types → file extension. */
export const ALLOWED_IMAGE_TYPES = new Map<string, string>([
['image/png', 'png'],
['image/jpeg', 'jpg'],
]);
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5 MB
/** Guards the served filename against path traversal. */
export function isSafeUploadName(name: string): boolean {
return /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..');
}
/** Content-type for a stored upload, by extension. */
export const UPLOAD_CONTENT_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};

View file

@ -293,8 +293,4 @@ const saved = Astro.url.searchParams.get('saved') === '1';
color: var(--on-surface-muted); color: var(--on-surface-muted);
margin: 0; margin: 0;
} }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style> </style>

View file

@ -1,256 +0,0 @@
---
/* ---------------------------------------------------------------------------
* /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 },
origin: Astro.url.origin,
};
// ── 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,17 +1,882 @@
--- ---
/* --------------------------------------------------------------------------- import AppLayout from '../../layouts/AppLayout.astro';
* /admin — redirect to the first registered resource. import {
* getAllInvites, getAllUsersPublic, revokeInvite,
* Auth-gated like every other admin page. Members hitting /admin without createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
* the fenja role land on /; admins land on the dispatches list view (the getUserPublicById, getAllJoinRequests,
* default Backstage home). createPulse, updatePulse, publishPulse, closePulse, deletePulse,
* ------------------------------------------------------------------------- */ getAllPulses, getPulseById, getPulseWithCounts,
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
import { groups } from '../../admin/resources'; setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
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('/');
const first = groups.flatMap((g) => g.resources)[0]; // Guard: fenja only
return Astro.redirect(first ? `/admin/${first.key}` : '/'); if (user.role !== 'fenja') {
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;
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 });
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 });
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 attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','beta','exploring'].includes(status)) {
formError = 'Title and status are required.';
} else if (action === 'create_roadmap') {
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder });
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,
});
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,
});
updateRoadmapItem(other.id, {
title: other.title, description: other.description, status: other.status,
target: other.target, display_order: item.display_order,
});
}
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;
// 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 -->
<div class="tabs">
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</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=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</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} 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

@ -1,55 +0,0 @@
/* ---------------------------------------------------------------------------
* POST /api/admin/upload fenja-only event photo upload.
*
* Accepts multipart/form-data with a single `file` (png/jpg, 5 MB), writes it
* to the upload dir under a random name, and returns { url } pointing at the
* /uploads/[file] serve route. The caller (admin image-upload field) stores
* that url in the event's photo_url.
* ------------------------------------------------------------------------- */
import type { APIRoute } from 'astro';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { UPLOAD_DIR, ALLOWED_IMAGE_TYPES, MAX_UPLOAD_BYTES } from '../../../lib/uploads';
export const prerender = false;
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
export const POST: APIRoute = async ({ locals, request }) => {
if (locals.user?.role !== 'fenja') return json({ error: 'Forbidden' }, 403);
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ error: 'Expected multipart form data.' }, 400);
}
const file = form.get('file');
if (!(file instanceof File) || file.size === 0) {
return json({ error: 'No file provided.' }, 400);
}
const ext = ALLOWED_IMAGE_TYPES.get(file.type);
if (!ext) return json({ error: 'Only PNG or JPG images are allowed.' }, 415);
if (file.size > MAX_UPLOAD_BYTES) {
return json({ error: 'Image must be 5 MB or smaller.' }, 413);
}
const name = `${randomUUID()}.${ext}`;
try {
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));
} catch {
return json({ error: 'Could not save the image. Try again.' }, 500);
}
return json({ url: `/uploads/${name}` }, 200);
};

View file

@ -524,10 +524,4 @@ const typeColors: Record<ContributionType, string> = {
white-space: nowrap; white-space: nowrap;
border-width: 0; border-width: 0;
} }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
/* Filter + sort tab groups wrap instead of overflowing. */
.feed-controls { flex-wrap: wrap; gap: var(--space-3); }
}
</style> </style>

View file

@ -191,8 +191,4 @@ if (Astro.request.method === 'POST') {
transition: opacity var(--duration-fast) var(--ease-standard); transition: opacity var(--duration-fast) var(--ease-standard);
} }
.btn-primary:hover { opacity: 0.9; } .btn-primary:hover { opacity: 0.9; }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style> </style>

View file

@ -38,8 +38,4 @@ const user = Astro.locals.user;
.page-title { margin: 0; } .page-title { margin: 0; }
.reading-col { max-width: var(--reading-max); } .reading-col { max-width: var(--reading-max); }
.lead { color: var(--on-surface-variant); } .lead { color: var(--on-surface-variant); }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style> </style>

View file

@ -1,10 +1,7 @@
--- ---
import AppLayout from '../../layouts/AppLayout.astro'; import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro'; import Avatar from '../../components/Avatar.astro';
import { import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
getDispatchWithPoll, getAdjacentDispatches,
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
} from '../../lib/db';
import { import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel, parseDispatchSlug, dispatchSlug, dispatchKindLabel,
dispatchKindPigment, roleLabel, dispatchKindPigment, roleLabel,
@ -17,38 +14,15 @@ const id = parseDispatchSlug(slugParam);
if (!id) return Astro.redirect('/dispatches'); if (!id) return Astro.redirect('/dispatches');
// Vote POST — handled before main render so we can refresh state const d = getDispatchById(id);
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'vote') {
const pulseId = Number(data.get('pulse_id'));
const optionIndex = Number(data.get('option_index'));
const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length) {
const wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
}
return Astro.redirect(Astro.url.pathname);
}
}
const d = getDispatchWithPoll(id, user.id);
if (!d || d.status !== 'published') return Astro.redirect('/dispatches'); if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
// Canonical-redirect when the slug changes after a rename — id is the authority // Canonical-redirect when the slug changes after a rename — id is the authority
const canonical = dispatchSlug(d); const canonical = dispatchSlug(d);
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`); if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
const totalMembers = countCabMembers();
const { prev, next } = getAdjacentDispatches(d.id); const { prev, next } = getAdjacentDispatches(d.id);
function closeDayLabel(closesAt: string): string {
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
}
function parseUtc(s: string): Date { function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z'); return new Date(s.replace(' ', 'T') + 'Z');
@ -89,51 +63,6 @@ const bodyHtml = renderMd(d.body);
<div class="body prose" set:html={bodyHtml} /> <div class="body prose" set:html={bodyHtml} />
{d.poll && (
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
<p class="inline-poll-question">{d.poll.question}</p>
<form method="POST" class="inline-poll-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={d.poll.id} />
{d.poll.options.map((opt, i) => {
const hasVoted = d.poll!.my_vote !== null;
const chosen = d.poll!.my_vote === i;
const closed = d.poll!.status !== 'open';
const count = d.poll!.votes_by_option[i] ?? 0;
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
const letter = String.fromCharCode(65 + i);
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['inline-poll-option', { chosen, closed }]}
disabled={closed && !chosen}
aria-pressed={chosen}
>
<span class="inline-poll-letter">{letter}</span>
<span class="inline-poll-text">{opt}</span>
{hasVoted && (
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
)}
{hasVoted && (
<span class="inline-poll-bar" aria-hidden="true">
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="inline-poll-count">
{d.poll.votes_total} of {totalMembers} have weighed in
{d.poll.status === 'open'
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
: ' · closed'}
</p>
</aside>
)}
<hr class="divider" /> <hr class="divider" />
<nav class="adjacent" aria-label="Adjacent dispatches"> <nav class="adjacent" aria-label="Adjacent dispatches">
@ -167,7 +96,7 @@ const bodyHtml = renderMd(d.body);
<style> <style>
.page { .page {
padding: var(--space-12) var(--space-20) var(--space-16); padding: var(--space-12) var(--space-20) var(--space-16);
max-width: 1080px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -265,95 +194,6 @@ const bodyHtml = renderMd(d.body);
margin: var(--space-6) 0 0; margin: var(--space-6) 0 0;
} }
/* ── Inline poll attached to the dispatch ──────────────────────── */
.inline-poll {
margin-top: var(--space-7);
background: rgba(185, 107, 88, 0.08);
border: none;
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.inline-poll-question {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.inline-poll-options {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.inline-poll-option {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--background);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
.inline-poll-option.chosen {
border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
}
.inline-poll-option.closed:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.inline-poll-pct {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
font-weight: 600;
letter-spacing: var(--tracking-wider);
color: var(--on-surface-variant);
font-variant-numeric: tabular-nums;
}
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
.inline-poll-option:disabled { opacity: 0.85; }
.inline-poll-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
.inline-poll-text { flex: 1; }
.inline-poll-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.inline-poll-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.55;
transition: width 600ms var(--ease-standard);
}
.inline-poll-count {
color: var(--on-surface-muted);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
margin: 0;
}
.adjacent { .adjacent {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -399,7 +239,6 @@ const bodyHtml = renderMd(d.body);
.adj-empty {} /* placeholder for missing prev/next slot */ .adj-empty {} /* placeholder for missing prev/next slot */
@media (max-width: 640px) { @media (max-width: 640px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.adjacent { grid-template-columns: 1fr; } .adjacent { grid-template-columns: 1fr; }
.adj-next { text-align: left; align-items: flex-start; } .adj-next { text-align: left; align-items: flex-start; }
.adj-next .adj-kind-pill { align-self: flex-start; } .adj-next .adj-kind-pill { align-self: flex-start; }

View file

@ -170,7 +170,6 @@ function fmt(iso: string): string {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.d-link { .d-link {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-3); gap: var(--space-3);

View file

@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
/* ── Hero ─────────────────────────────────────────────────────── */ /* ── Hero ─────────────────────────────────────────────────────── */
.hero { .hero {
background: var(--ink); background: var(--ink);
color: var(--on-ink); color: var(--ink-text);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.75rem; padding: 1.75rem;
display: flex; display: flex;
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: var(--on-ink-muted); color: var(--ink-muted);
} }
.hero-eyebrow { .hero-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
left: 100px; left: 100px;
top: 0; bottom: 0; top: 0; bottom: 0;
width: 0.5px; width: 0.5px;
background: var(--ink-divider); background: rgba(232, 224, 208, 0.2);
} }
.hero-date { display: flex; flex-direction: column; gap: 2px; } .hero-date { display: flex; flex-direction: column; gap: 2px; }
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-ink); color: var(--ink-text);
} }
.hero-day { .hero-day {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 2.75rem; font-size: 2.75rem;
line-height: 1; line-height: 1;
color: var(--on-ink); color: var(--ink-text);
} }
.hero-detail { padding-left: var(--space-5); } .hero-detail { padding-left: var(--space-5); }
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
font-weight: 400; font-weight: 400;
font-size: 1.75rem; font-size: 1.75rem;
line-height: 1.2; line-height: 1.2;
color: var(--on-ink); color: var(--ink-text);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
} }
.hero-desc { .hero-desc {
color: var(--on-ink-body); color: rgba(232, 224, 208, 0.85);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
max-width: 40rem; max-width: 40rem;
} }
.hero-meta { .hero-meta {
color: var(--on-ink-muted); color: var(--ink-muted);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
margin: 0; margin: 0;
} }
.hero-foot { .hero-foot {
border-top: 0.5px solid var(--ink-divider); border-top: 0.5px solid rgba(232, 224, 208, 0.2);
padding-top: var(--space-4); padding-top: var(--space-4);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
} }
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); } .hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat { .hero-foot-stat {
color: var(--on-ink-muted); color: var(--ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); } .hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta { .hero-cta {
background: var(--on-ink); background: var(--ink-text);
color: var(--ink); color: var(--ink);
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-cta:hover { opacity: 0.85; } .hero-cta:hover { opacity: 0.85; }
.hero-confirmed { .hero-confirmed {
color: var(--on-ink); color: var(--ink-text);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: 600; font-weight: 600;
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
padding: 10px 16px; padding: 10px 16px;
border: 0.5px solid rgba(255, 252, 247, 0.4); border: 0.5px solid rgba(232, 224, 208, 0.4);
border-radius: 999px; border-radius: 999px;
} }
.hero-change { .hero-change {
background: transparent; background: transparent;
border: none; border: none;
color: var(--on-ink-muted); color: var(--ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
} }
.hero-empty-line { .hero-empty-line {
color: var(--on-ink); color: var(--ink-text);
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 1.25rem; font-size: 1.25rem;
margin: auto; margin: auto;
@ -504,7 +504,6 @@ const heroAudience = hero?.audience ?? 'Members only';
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.hero-body { grid-template-columns: 1fr; } .hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; } .hero-body::after { display: none; }
.past-grid { grid-template-columns: 1fr; } .past-grid { grid-template-columns: 1fr; }

View file

@ -154,7 +154,6 @@ function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.past-list { grid-template-columns: 1fr; } .past-list { grid-template-columns: 1fr; }
} }
</style> </style>

View file

@ -563,12 +563,4 @@ const innofoundarLogoExists = existsSync(join(process.cwd(), 'public/innofounder
margin: 0; margin: 0;
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
} }
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
/* Stack the two-column rows. */
.fenja-row,
.ask-grid { grid-template-columns: 1fr; }
}
</style> </style>

View file

@ -199,7 +199,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.m-row { grid-template-columns: 52px 1fr; } .m-row { grid-template-columns: 52px 1fr; }
.m-tags { .m-tags {
grid-column: 1 / -1; grid-column: 1 / -1;

View file

@ -421,13 +421,4 @@ const tiers = [
margin: 0; margin: 0;
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
} }
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
/* Stack the label / content / tag rows. */
.arch-row,
.tier-row,
.extends-item { grid-template-columns: 1fr; gap: var(--space-2); }
}
</style> </style>

File diff suppressed because it is too large Load diff

View file

@ -1,112 +1,212 @@
--- ---
import { readFileSync } from 'fs';
import { join } from 'path';
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro'; import { marked } from 'marked';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
const items = getAllRoadmapItems() // Single-file roadmap — not a content collection
.sort((a, b) => a.display_order - b.display_order || a.id - b.id); const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
// Strip YAML frontmatter
const body = raw.replace(/^---[\s\S]*?---\n/, '');
// Parse sections by ## headings
function parseSections(md: string) {
const sectionRe = /^## (.+)$/gm;
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
const matches = [...md.matchAll(sectionRe)];
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index! + m[0].length;
const end = matches[i + 1]?.index ?? md.length;
const sectionBody = md.slice(start, end).trim();
// Each item starts with **Title** — description
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
let itemMatch: RegExpExecArray | null;
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
const rawBody = itemMatch[2].trim();
const pilotOnly = rawBody.includes('`pilot-only`');
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
}
sections.push({ title: m[1], items });
}
return sections;
}
const sections = parseSections(body);
const horizonColors: Record<string, string> = {
'In progress': 'var(--pigment-copper)',
'Next': 'var(--pigment-ochre)',
'Later': 'var(--pigment-indigo)',
};
--- ---
<AppLayout title="Roadmap" user={user}> <AppLayout title="Roadmap" user={user}>
<article class="roadmap-page"> <div class="page">
<!-- Single centred header — merges the page lead with the route's <header class="page-header">
interaction hints. --> <h1 class="display-md page-title">What we are building.</h1>
<header class="roadmap-header"> <p class="subtitle">
<h1 class="roadmap-title">Roadmap</h1> Three horizons. What is in progress now, what comes next,
<p class="roadmap-sub"> and what is further out. This is the live picture.
A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Tap or hover any milestone
for the full story. Drag or scroll to move.
</p> </p>
</header> </header>
<!-- Legend lives above the route now — reads as a key the eye picks <div class="horizons">
up just before walking the path. --> {sections.map((section) => (
<div class="roadmap-legend" aria-label="Status legend"> <section class="horizon">
<span><i style="background:#6d8c7c"></i>Shipping</span> <div class="horizon-header">
<span><i style="background:#b96b58"></i>In beta</span> <span
<span><i style="background:#5a6d83"></i>Planned</span> class="horizon-dot"
<span><i style="background:#b4b2a9"></i>Exploring</span> style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
<span><i style="background:#d4d2c8"></i>Considering</span> aria-hidden="true"
/>
<h2 class="headline-sm horizon-title">{section.title}</h2>
</div>
<ul class="item-list">
{section.items.map((item) => (
<li class="item">
<div class="item-header">
<h3 class="item-title body-lg">{item.title}</h3>
{item.pilotOnly && (
<span class="pilot-badge label-sm" title="Available to pilot participants only">
Pilot
</span>
)}
</div>
<p class="body-md item-body">{item.body}</p>
</li>
))}
</ul>
</section>
))}
</div> </div>
<RoadmapRoute items={items} /> </div>
<!-- Latest dispatch sits at the foot of the page with generous
space above so it reads as a separate beat, not a continuation
of the route. -->
<LatestDispatchBanner />
</article>
</AppLayout> </AppLayout>
<style> <style>
.roadmap-page { .page {
padding: 0 36px 80px; padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
/* ── Centred header ──────────────────────────────────────────── */ /* ── Header ──────────────────────────────────────────────────────── */
.roadmap-header { .page-header {
text-align: center; max-width: 44rem;
max-width: 640px; margin-bottom: var(--space-12);
margin: 0 auto 56px; /* generous gap to the legend */
padding-top: 96px;
}
.roadmap-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 48px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface);
margin: 0 0 14px;
}
.roadmap-sub {
font-size: 14px;
line-height: 1.65;
color: var(--on-surface-variant);
margin: 0 auto;
max-width: 520px;
} }
/* ── Legend (above the route, key-style) ─────────────────────── */ .eyebrow {
.roadmap-legend { letter-spacing: var(--tracking-wider);
display: flex;
justify-content: center;
gap: 24px;
margin: 0 auto 14px; /* tight to the route — they're paired */
flex-wrap: wrap;
}
.roadmap-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: var(--on-surface-muted);
margin-bottom: var(--space-3);
} }
.roadmap-legend i {
width: 8px; .page-title {
height: 8px; margin-bottom: var(--space-5);
border-radius: 50%; }
.subtitle {
color: var(--on-surface-variant);
max-width: var(--reading-max);
margin: 0;
}
/* ── Horizons ────────────────────────────────────────────────────── */
.horizons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-8);
align-items: start;
}
.horizon {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.horizon-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: var(--ghost-border);
}
.horizon-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Dispatch banner (foot of page, generous breathing room) ── */ .horizon-title {
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; } font-family: var(--font-sans);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-size: var(--text-label-md);
margin: 0;
color: var(--on-surface-variant);
}
@media (max-width: 767px) { /* ── Items ───────────────────────────────────────────────────────── */
.roadmap-page { padding: 0 24px 64px; } .item-list {
.roadmap-header { padding-top: 72px; margin-bottom: 40px; } list-style: none;
.roadmap-title { font-size: 36px; } padding: 0;
.roadmap-legend { margin-bottom: 12px; } margin: 0;
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; } display: flex;
flex-direction: column;
gap: var(--space-6);
}
.item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.item-header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.item-title {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
margin: 0;
color: var(--on-surface);
flex: 1;
}
.pilot-badge {
flex-shrink: 0;
padding: 0.2em var(--space-2);
background: var(--surface-container);
border-radius: var(--radius-full);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
white-space: nowrap;
}
.item-body {
margin: 0;
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
} }
</style> </style>

View file

@ -240,8 +240,4 @@ const next = sorted[currentIndex - 1] ?? null;
.post-nav-link:hover .nav-title { .post-nav-link:hover .nav-title {
color: var(--on-surface); color: var(--on-surface);
} }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style> </style>

View file

@ -168,11 +168,4 @@ const updates = allUpdates.sort(
color: var(--on-surface-variant); color: var(--on-surface-variant);
border-bottom: none; border-bottom: none;
} }
/* ── Mobile (≤720px) ────────────────────────────────────────────── */
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
/* Date stacks above the content instead of a cramped 9rem column. */
.update-item { grid-template-columns: 1fr; gap: var(--space-2); }
}
</style> </style>

View file

@ -1,35 +0,0 @@
/* ---------------------------------------------------------------------------
* GET /uploads/[file] serve an uploaded event photo from the upload dir.
*
* Runtime-written files live outside the build output, so they're streamed
* here rather than served as static assets. Gated by the global auth
* middleware (viewers are logged in; the <img> request carries the session
* cookie). Filename is validated to prevent path traversal.
* ------------------------------------------------------------------------- */
import type { APIRoute } from 'astro';
import { readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
import { UPLOAD_DIR, isSafeUploadName, UPLOAD_CONTENT_TYPES } from '../../lib/uploads';
export const prerender = false;
export const GET: APIRoute = async ({ params }) => {
const file = params.file ?? '';
if (!isSafeUploadName(file)) return new Response('Not found', { status: 404 });
const type = UPLOAD_CONTENT_TYPES[extname(file).toLowerCase()];
if (!type) return new Response('Not found', { status: 404 });
try {
const buf = await readFile(join(UPLOAD_DIR, file));
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new Response('Not found', { status: 404 });
}
};

View file

@ -289,10 +289,4 @@ const user = Astro.locals.user;
color: var(--on-surface-variant); color: var(--on-surface-variant);
margin: 0; margin: 0;
} }
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
.bifrost-context-section { grid-template-columns: 1fr; gap: var(--space-3); }
}
</style> </style>

View file

@ -232,27 +232,23 @@ a:hover {
.ghost-border { border: var(--ghost-border); } .ghost-border { border: var(--ghost-border); }
.ghost-border-bottom { border-bottom: var(--ghost-border); } .ghost-border-bottom { border-bottom: var(--ghost-border); }
/* --- Section link black serif italic, underlined, larger. /* --- Section link prominent italic serif, placed at the bottom of
Placed at the bottom of its respective box or article. its respective box or article. See points 8 + 10 in the v3 spec:
Italics are reserved for links + the Bifrost wordmark. --- */ italics are reserved for links + the Bifrost wordmark. --- */
.section-link { .section-link {
display: inline-block; display: inline-block;
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic; font-style: italic;
font-size: var(--text-title-lg); /* 1.125rem — larger than body */ font-size: var(--text-body-md);
color: var(--on-surface); color: var(--pigment-terracotta);
text-decoration: underline; text-decoration: none;
text-decoration-thickness: 0.5px;
text-underline-offset: 4px;
border-bottom: none; border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard); transition: opacity var(--duration-fast) var(--ease-standard);
} }
.section-link:hover { .section-link:hover {
color: var(--on-surface);
text-decoration: underline;
text-decoration-thickness: 1px;
border-bottom: none; border-bottom: none;
opacity: 0.78; opacity: 0.78;
color: var(--pigment-terracotta);
} }
.section-link--ink { .section-link--ink {
color: var(--ink-text); color: var(--ink-text);

View file

@ -45,14 +45,8 @@
--surface-card: #ffffff; --surface-card: #ffffff;
--surface-card-border: rgba(0, 0, 0, 0.08); --surface-card-border: rgba(0, 0, 0, 0.08);
--ink: #2c3a52; /* deep indigo — membership card + event hero */ --ink: #2c3a52; /* deep indigo — membership card + event hero */
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */ --ink-text: #e8e0d0; /* readable cream on --ink */
--ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */ --ink-muted: #b8a989; /* muted label tone on --ink */
/* --- v4: bleached cream on indigo surfaces (replaces --ink-text) --- */
--on-ink: #fffcf7; /* primary text on --ink */
--on-ink-body: rgba(255, 252, 247, 0.85); /* body copy */
--on-ink-muted: rgba(255, 252, 247, 0.65); /* tracked labels */
--ink-divider: rgba(255, 252, 247, 0.18); /* 0.5px lines on --ink */
/* --- Semantic state mappings --- */ /* --- Semantic state mappings --- */
--color-success: var(--pigment-copper); --color-success: var(--pigment-copper);
@ -71,14 +65,14 @@
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); --text-display-md: clamp(2.5rem, 4vw, 3.5rem);
--text-headline-lg: 2.25rem; --text-headline-lg: 2.25rem;
--text-headline-md: 1.75rem; --text-headline-md: 1.75rem;
--text-headline-sm: 1.4375rem; /* 23px (was 22) */ --text-headline-sm: 1.375rem;
--text-title-lg: 1.1875rem; /* 19px (was 18) */ --text-title-lg: 1.125rem;
--text-title-md: 1.0625rem; /* 17px (was 16) */ --text-title-md: 1rem;
--text-body-lg: 1.125rem; /* 18px (was 17) */ --text-body-lg: 1.0625rem;
--text-body-md: 1.0625rem; /* 17px (was 16) — base body */ --text-body-md: 1rem;
--text-body-sm: 0.9375rem; /* 15px (was 14) */ --text-body-sm: 0.875rem;
--text-label-md: 0.875rem; /* 14px (was 13) */ --text-label-md: 0.8125rem;
--text-label-sm: 0.8125rem; /* 13px (was 12) */ --text-label-sm: 0.75rem;
/* --- Tracking --- */ /* --- Tracking --- */
--tracking-tight: -0.02em; --tracking-tight: -0.02em;
@ -135,6 +129,6 @@
--duration-slow: 420ms; --duration-slow: 420ms;
/* --- Layout --- */ /* --- Layout --- */
--content-max: 72rem; /* 1152px */ --content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
--reading-max: 42rem; /* 672px */ --reading-max: 42rem; /* 672px */
} }

View file

@ -1,169 +0,0 @@
/* ---------------------------------------------------------------------------
* 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',
'image-upload',
]);
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

@ -1,113 +0,0 @@
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({});
});
});

View file

@ -1,94 +0,0 @@
import { describe, it, expect } from 'vitest';
import { computeRouteLayout, travelledStopFor } from '../src/lib/roadmap-layout.js';
function isStrictlyIncreasing(xs: number[]): boolean {
for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) return false;
return true;
}
describe('computeRouteLayout', () => {
it('1 item — produces a valid single-point M path on the centreline', () => {
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(1);
expect(out.midY).toBe(210); // trackHeight 420 / 2
expect(out.itemY).toEqual([out.midY]);
expect(out.cardSide).toEqual(['below']);
expect(out.pathD.startsWith('M ')).toBe(true);
expect(out.pathD).not.toContain('C ');
// Target usable width = 1000 * 0.8 = 800; trackWidth = 800 + 60*2 = 920
expect(out.trackWidth).toBe(920);
});
it('2 items — cards hang toward centreline (both below: i=0 centre, i=1 above-centre)', () => {
const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(2);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// i=0: centreline (below by convention). i=1: dot above centre → card below.
expect(out.cardSide).toEqual(['below', 'below']);
expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(1);
});
it('3 items — cards toward centreline; amplitude multiplier ramps', () => {
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
// i=0 centre (below), i=1 above-centre (card below), i=2 below-centre (card above).
expect(out.cardSide).toEqual(['below', 'below', 'above']);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// First item on centreline, second above (smaller y), third below.
expect(out.itemY[0]).toBe(out.midY);
expect(out.itemY[1]).toBeLessThan(out.midY);
expect(out.itemY[2]).toBeGreaterThan(out.midY);
expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY));
});
it('7 items — every card grows toward the centreline, never away from it', () => {
const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 });
expect(out.itemX).toHaveLength(7);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// i: 0 1 2 3 4 5 6 — odd indices dot-above-centre (card below), even
// indices >0 dot-below-centre (card above). i=0 default below.
expect(out.cardSide).toEqual(['below', 'below', 'above', 'below', 'above', 'below', 'above']);
// Spot-check: every non-i=0 card's side is opposite to its dot's
// offset from centre — i.e. cards always shrink toward midY.
for (let i = 1; i < out.itemX.length; i += 1) {
const dotAbove = out.itemY[i] < out.midY;
if (dotAbove) expect(out.cardSide[i]).toBe('below');
else expect(out.cardSide[i]).toBe('above');
}
expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(6);
});
it('20 items — data-driven width wins over the 80% target', () => {
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// (20 - 1) * 320 + 60 * 2 = 6200; clearly beats 800 * 0.8 + 120 = 760.
expect(out.trackWidth).toBe(6200);
});
it('few items on a wide viewport — track ≈ 80% of viewport + padding', () => {
// 3 items at viewport 1400. Data-driven = 2 * 320 + 120 = 760.
// 80% target = 1400 * 0.8 + 120 = 1240 — should win.
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1400 });
expect(out.trackWidth).toBe(1240);
});
});
describe('travelledStopFor', () => {
it('returns 0 when no items have shipped', () => {
expect(travelledStopFor(['exploring', 'considering'])).toBe(0);
expect(travelledStopFor([])).toBe(0);
});
it('returns (lastShippingIndex + 0.5) / itemCount', () => {
// [shipping, shipping, in_beta, exploring] → lastShipping = 1 → (1.5)/4 = 0.375
expect(travelledStopFor(['shipping', 'shipping', 'in_beta', 'exploring'])).toBeCloseTo(0.375, 5);
});
it('clamps to 0.98 when every item has shipped', () => {
expect(travelledStopFor(['shipping', 'shipping', 'shipping'])).toBeCloseTo(0.833, 2);
// even with 100 items all shipping, clamps to 0.98
const allShipping = Array(100).fill('shipping') as ('shipping')[];
expect(travelledStopFor(allShipping)).toBeLessThanOrEqual(0.98);
});
});

View file

@ -1,33 +0,0 @@
import { describe, it, expect } from 'vitest';
import { tenureMilestone } from '../src/lib/format.js';
describe('tenureMilestone — copy variants by day count', () => {
it('0 days reads "Day one."', () => {
expect(tenureMilestone(0)).toBe('Day one. The team is reading every note you leave.');
});
it('1 day reads "Day 2." (off-by-one — day 1 is the first 24h after joining)', () => {
expect(tenureMilestone(1)).toBe('Day 2. The team is reading every note you leave.');
});
it('7 days enters the "{n} days in" bucket', () => {
expect(tenureMilestone(7)).toBe('7 days in. The team is reading every note you leave.');
});
it('22 days reads "A few weeks in."', () => {
expect(tenureMilestone(22)).toBe('A few weeks in. The team is reading every note you leave.');
});
it('60 days reads "{n_months} months in." (months = floor(days/30))', () => {
expect(tenureMilestone(60)).toBe('2 months in. The team is reading every note you leave.');
});
it('200 days reads "Almost a year in." (switches to "Still" suffix)', () => {
expect(tenureMilestone(200)).toBe('Almost a year in. Still reading every note you leave.');
});
it('400 days reads "{n_years} year(s) in."', () => {
expect(tenureMilestone(400)).toBe('1 year in. Still reading every note you leave.');
expect(tenureMilestone(730)).toBe('2 years in. Still reading every note you leave.');
});
});