Compare commits
No commits in common. "5702859e3784fec7a975d3e609b7fa54996d27f6" and "66c3f6492f9bf4f3cb533007c566e8f17b4eaf83" have entirely different histories.
5702859e37
...
66c3f6492f
80 changed files with 2604 additions and 8797 deletions
|
|
@ -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(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)",
|
||||
"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)"
|
||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,6 +8,3 @@ node_modules/
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
progress.md
|
||||
|
||||
# Uploaded event photos (runtime, persists on the VPS)
|
||||
data/uploads/
|
||||
|
|
|
|||
1
SPEC.md
1
SPEC.md
|
|
@ -255,7 +255,6 @@ Listed so Claude Code does not wander into them:
|
|||
|
||||
- Email sending (invites are copied manually; no transactional email provider)
|
||||
- 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
|
||||
- Threaded discussion on contributions (replies from Fenja only, one level deep)
|
||||
- Dark mode
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -61,9 +61,6 @@ const newCabs = [
|
|||
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
||||
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
||||
{ 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(`
|
||||
|
|
@ -78,15 +75,7 @@ for (const c of newCabs) {
|
|||
// 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.
|
||||
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
||||
const tenureWeeks = {
|
||||
'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 tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
|
||||
|
||||
const setCabMeta = db.prepare(`
|
||||
UPDATE users
|
||||
|
|
@ -119,21 +108,6 @@ const cabMeta = {
|
|||
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
||||
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) {
|
||||
|
|
@ -169,17 +143,14 @@ const nowIso = (offsetSeconds = 0) => {
|
|||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
};
|
||||
|
||||
// ── Poll attached to a dispatch (the decision) — open, 2/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.
|
||||
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
|
||||
const pulseOptions = [
|
||||
'Locking down on-prem deployment first',
|
||||
'Pushing the traceability layer to GA',
|
||||
'Going wide on document ingestion',
|
||||
'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)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
|
|
@ -189,36 +160,27 @@ const decisionPulseId = db.prepare(`
|
|||
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
||||
).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 (?,?,?,?)')
|
||||
.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 (?,?,?,?)')
|
||||
.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
|
||||
// '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.
|
||||
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
|
||||
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: '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: '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: '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: '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' },
|
||||
{ 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 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: '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 and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
|
||||
];
|
||||
|
||||
const insertRoad = db.prepare(`
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`);
|
||||
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -247,9 +209,7 @@ for (const c of contribs) {
|
|||
const dispatchSeed = [
|
||||
{ kind: 'decision', ageDays: 2,
|
||||
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.
|
||||
|
||||
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.`,
|
||||
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
|
||||
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.
|
||||
|
|
@ -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 insertDispatch = db.prepare(`
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
|
||||
VALUES (?,?,?,?,?,'published',?,?,?,?)
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,'published',?,?,?)
|
||||
`);
|
||||
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
||||
const d = dispatchSeed[i];
|
||||
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
||||
const authorId = fenjas[i % fenjas.length].id;
|
||||
// Attach the decision-pulse to the decision dispatch — this is the demo
|
||||
// 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);
|
||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
VALUES (?,?,?,?,?)
|
||||
`);
|
||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
|
||||
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
|
||||
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
|
||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
|
||||
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
||||
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));
|
||||
|
||||
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
|
||||
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
|
||||
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
|
||||
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
|
||||
console.log(' contributions: 3 (most recent has 3 reactions)');
|
||||
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
||||
console.log(' events: dinner + studio hours + working session, 2 past');
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
|
|||
// schema's three statuses. In-progress items are actively being built and
|
||||
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
|
||||
const SECTION_STATUS = {
|
||||
'In progress': { status: 'in_beta', target: null },
|
||||
'In progress': { status: 'beta', target: null },
|
||||
'Next': { status: 'exploring', target: 'Next quarter' },
|
||||
'Later': { status: 'exploring', target: 'Later this year' },
|
||||
};
|
||||
|
|
|
|||
1432
src/admin/admin.css
1432
src/admin/admin.css
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 (2–4 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>[];
|
||||
}
|
||||
|
|
@ -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: {},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Resource registry — single source of truth for sidebar navigation.
|
||||
*
|
||||
* Groups are populated incrementally across steps 8–10 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],
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
@ -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 member’s 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -51,7 +51,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
<style>
|
||||
.m-card {
|
||||
background: var(--ink);
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
|
|
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--on-ink);
|
||||
background: var(--ink-text);
|
||||
color: var(--ink);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -78,7 +78,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
font-weight: 500;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.m-name {
|
||||
|
|
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
|
|||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.15;
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
|
|||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.m-since-value {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
.m-tags {
|
||||
|
|
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
|
|||
gap: 6px;
|
||||
}
|
||||
.m-tag {
|
||||
border: 0.5px solid rgba(255, 252, 247, 0.3);
|
||||
color: var(--on-ink);
|
||||
border: 0.5px solid rgba(232, 224, 208, 0.3);
|
||||
color: var(--ink-text);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
44
src/components/admin/ActivityTab.astro
Normal file
44
src/components/admin/ActivityTab.astro
Normal 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>
|
||||
175
src/components/admin/DispatchesTab.astro
Normal file
175
src/components/admin/DispatchesTab.astro
Normal 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>
|
||||
218
src/components/admin/EventsTab.astro
Normal file
218
src/components/admin/EventsTab.astro
Normal 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>
|
||||
261
src/components/admin/PulsesTab.astro
Normal file
261
src/components/admin/PulsesTab.astro
Normal 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 (2–4)</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>
|
||||
179
src/components/admin/RoadmapTab.astro
Normal file
179
src/components/admin/RoadmapTab.astro
Normal 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>
|
||||
90
src/components/admin/UserEditTab.astro
Normal file
90
src/components/admin/UserEditTab.astro
Normal 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>
|
||||
|
|
@ -16,6 +16,7 @@ const navLinks = [
|
|||
|
||||
const footerLinks = [
|
||||
{ href: '/vision', label: 'Vision' },
|
||||
{ href: '/council-manifesto', label: 'Council manifesto' },
|
||||
];
|
||||
|
||||
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>
|
||||
</a>
|
||||
|
||||
<button
|
||||
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">
|
||||
<nav class="nav-right" aria-label="Main navigation">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<a
|
||||
href={href}
|
||||
|
|
@ -125,52 +112,35 @@ const year = new Date().getFullYear();
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--space-3);
|
||||
border-bottom: none;
|
||||
color: var(--on-surface);
|
||||
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
|
||||
}
|
||||
.wordmark-link:hover {
|
||||
border-bottom: none;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.wordmark {
|
||||
height: 30px; /* 50% larger than the prior 20px lockup */
|
||||
height: 22px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
.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);
|
||||
font-family: var(--font-serif);
|
||||
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);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.wordmark-project {
|
||||
font-size: 20px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-md);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.wordmark-bifrost {
|
||||
display: inline-block;
|
||||
font-size: 19px;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
padding: 2px 0;
|
||||
vertical-align: baseline;
|
||||
font-weight: 400;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--pigment-terracotta) 0%,
|
||||
|
|
@ -184,23 +154,6 @@ const year = new Date().getFullYear();
|
|||
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-right {
|
||||
display: flex;
|
||||
|
|
@ -212,15 +165,14 @@ const year = new Date().getFullYear();
|
|||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
margin: 0 18px;
|
||||
background: var(--ghost-border-color);
|
||||
margin: 0 var(--space-2);
|
||||
transform: scaleX(0.5);
|
||||
transform-origin: center;
|
||||
}
|
||||
.nav-logout-form { display: inline-flex; }
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 500;
|
||||
|
|
@ -231,17 +183,17 @@ const year = new Date().getFullYear();
|
|||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
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 {
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container-low);
|
||||
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 {
|
||||
color: var(--pigment-terracotta);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
/* ── User zone ──────────────────────────────────────────────────── */
|
||||
|
|
@ -334,80 +286,4 @@ const year = new Date().getFullYear();
|
|||
color: var(--on-surface-variant);
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
207
src/lib/db.ts
207
src/lib/db.ts
|
|
@ -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);
|
||||
}
|
||||
|
||||
/** 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
|
||||
* cab and the user had none; null otherwise. Callers may ignore. */
|
||||
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 {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
|
@ -314,15 +304,6 @@ export function getAllInvites(): (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 ────────────────────────────────────────────────
|
||||
|
||||
export function createContribution(data: {
|
||||
|
|
@ -476,20 +457,6 @@ export function getAllJoinRequests(): 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 ─────────────────────────────────────────────────
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const r = db.prepare(
|
||||
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
|
||||
|
|
@ -698,7 +650,7 @@ export function countPulseParticipants(pulseId: number): number {
|
|||
|
||||
// ── Roadmap items ────────────────────────────────────────────────
|
||||
|
||||
export type RoadmapStatus = 'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering';
|
||||
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
|
||||
|
||||
export interface RoadmapItem {
|
||||
id: number;
|
||||
|
|
@ -708,7 +660,6 @@ export interface RoadmapItem {
|
|||
target: string | null;
|
||||
display_order: number;
|
||||
shipped_at: string | null;
|
||||
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -723,32 +674,20 @@ export function createRoadmapItem(data: {
|
|||
status: RoadmapStatus;
|
||||
target?: string | null;
|
||||
display_order?: number;
|
||||
metadata_text?: string | null;
|
||||
}): number {
|
||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
||||
const requestedOrder = data.display_order ?? 0;
|
||||
|
||||
return db.transaction(() => {
|
||||
// Cascade: insert at position N shifts every existing item at or after N
|
||||
// down by one, keeping the order dense.
|
||||
db.prepare(
|
||||
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?'
|
||||
).run(requestedOrder);
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).run(
|
||||
data.title,
|
||||
data.description,
|
||||
data.status,
|
||||
data.target ?? null,
|
||||
requestedOrder,
|
||||
data.display_order ?? 0,
|
||||
shipped_at,
|
||||
data.metadata_text ?? null,
|
||||
);
|
||||
return Number(r.lastInsertRowid);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -761,10 +700,9 @@ export function updateRoadmapItem(id: number, data: {
|
|||
status: RoadmapStatus;
|
||||
target: string | null;
|
||||
display_order: number;
|
||||
metadata_text?: string | null;
|
||||
}): { shippedNow: boolean } {
|
||||
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
||||
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
||||
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
||||
|
||||
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', ' ')
|
||||
: current.shipped_at;
|
||||
|
||||
return db.transaction(() => {
|
||||
// Cascade neighbours when display_order changes.
|
||||
// Moving forward (A → B, B > A): rows in (A, B] shift down by 1.
|
||||
// Moving back (A → B, B < A): rows in [B, A) shift up by 1.
|
||||
const from = current.display_order;
|
||||
const to = data.display_order;
|
||||
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(`
|
||||
UPDATE roadmap_items
|
||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||
shipped_at = ?, 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);
|
||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
|
||||
|
||||
return { shippedNow };
|
||||
})();
|
||||
}
|
||||
|
||||
export function deleteRoadmapItem(id: number): void {
|
||||
db.transaction(() => {
|
||||
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 {
|
||||
|
|
@ -1093,7 +1004,6 @@ export interface Dispatch {
|
|||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pulse_id: number | null; // attached poll, if any
|
||||
}
|
||||
|
||||
export interface DispatchWithAuthor extends Dispatch {
|
||||
|
|
@ -1102,18 +1012,6 @@ export interface DispatchWithAuthor extends Dispatch {
|
|||
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: {
|
||||
title: string;
|
||||
body: string;
|
||||
|
|
@ -1121,99 +1019,34 @@ export function createDispatch(data: {
|
|||
kind: DispatchKind;
|
||||
author_id: number;
|
||||
status: DispatchStatus;
|
||||
poll?: DispatchPollInput | null;
|
||||
}): number {
|
||||
const published_at = data.status === 'published'
|
||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
: null;
|
||||
return db.transaction(() => {
|
||||
let pulseId: number | null = null;
|
||||
if (data.poll && data.poll.options.length >= 2) {
|
||||
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: 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);
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
|
||||
return Number(r.lastInsertRowid);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Update a dispatch and, optionally, manage its attached poll. */
|
||||
export function updateDispatch(id: number, data: {
|
||||
title: string;
|
||||
body: string;
|
||||
excerpt: string | null;
|
||||
kind: DispatchKind;
|
||||
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 {
|
||||
db.transaction(() => {
|
||||
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
|
||||
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
|
||||
if (!cur) return;
|
||||
|
||||
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')
|
||||
SET title = ?, body = ?, excerpt = ?, kind = ?, author_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 };
|
||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
|
||||
}
|
||||
|
||||
/** Promote draft → published, stamping published_at = now() on first publish.
|
||||
* Idempotent: if already published, published_at is preserved. Also opens
|
||||
* any attached draft poll so members can start voting. */
|
||||
* Idempotent: if already published, published_at is preserved. */
|
||||
export function publishDispatch(id: number): void {
|
||||
db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE dispatches
|
||||
SET status = 'published',
|
||||
|
|
@ -1221,19 +1054,11 @@ export function publishDispatch(id: number): void {
|
|||
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) publishPulse(row.pulse_id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Archive a dispatch. Leaves published_at intact for history. Closes any
|
||||
* attached open poll so the bar charts read final. */
|
||||
/** Archive a dispatch. Leaves published_at intact for history. */
|
||||
export function archiveDispatch(id: number): void {
|
||||
db.transaction(() => {
|
||||
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 {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -293,8 +293,4 @@ const saved = Astro.url.searchParams.get('saved') === '1';
|
|||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,17 +1,882 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* /admin — redirect to the first registered resource.
|
||||
*
|
||||
* Auth-gated like every other admin page. Members hitting /admin without
|
||||
* the fenja role land on /; admins land on the dispatches list view (the
|
||||
* default Backstage home).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { groups } from '../../admin/resources';
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import {
|
||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||
createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
|
||||
getUserPublicById, getAllJoinRequests,
|
||||
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
|
||||
getAllPulses, getPulseById, getPulseWithCounts,
|
||||
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
|
||||
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;
|
||||
if (user.role !== 'fenja') return Astro.redirect('/');
|
||||
|
||||
const first = groups.flatMap((g) => g.resources)[0];
|
||||
return Astro.redirect(first ? `/admin/${first.key}` : '/');
|
||||
// Guard: fenja only
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -524,10 +524,4 @@ const typeColors: Record<ContributionType, string> = {
|
|||
white-space: nowrap;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -191,8 +191,4 @@ if (Astro.request.method === 'POST') {
|
|||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,4 @@ const user = Astro.locals.user;
|
|||
.page-title { margin: 0; }
|
||||
.reading-col { max-width: var(--reading-max); }
|
||||
.lead { color: var(--on-surface-variant); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import {
|
||||
getDispatchWithPoll, getAdjacentDispatches,
|
||||
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
|
||||
} from '../../lib/db';
|
||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
||||
import {
|
||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||
dispatchKindPigment, roleLabel,
|
||||
|
|
@ -17,38 +14,15 @@ const id = parseDispatchSlug(slugParam);
|
|||
|
||||
if (!id) return Astro.redirect('/dispatches');
|
||||
|
||||
// Vote POST — handled before main render so we can refresh state
|
||||
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);
|
||||
const d = getDispatchById(id);
|
||||
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
||||
|
||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||
const canonical = dispatchSlug(d);
|
||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||
|
||||
const totalMembers = countCabMembers();
|
||||
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 {
|
||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||
return new Date(s.replace(' ', 'T') + 'Z');
|
||||
|
|
@ -89,51 +63,6 @@ const bodyHtml = renderMd(d.body);
|
|||
|
||||
<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" />
|
||||
|
||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||
|
|
@ -167,7 +96,7 @@ const bodyHtml = renderMd(d.body);
|
|||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: 1080px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -265,95 +194,6 @@ const bodyHtml = renderMd(d.body);
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -399,7 +239,6 @@ const bodyHtml = renderMd(d.body);
|
|||
.adj-empty {} /* placeholder for missing prev/next slot */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.adjacent { grid-template-columns: 1fr; }
|
||||
.adj-next { text-align: left; align-items: flex-start; }
|
||||
.adj-next .adj-kind-pill { align-self: flex-start; }
|
||||
|
|
|
|||
|
|
@ -170,7 +170,6 @@ function fmt(iso: string): string {
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.d-link {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
background: var(--ink);
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem;
|
||||
display: flex;
|
||||
|
|
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.hero-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
|
|
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
left: 100px;
|
||||
top: 0; bottom: 0;
|
||||
width: 0.5px;
|
||||
background: var(--ink-divider);
|
||||
background: rgba(232, 224, 208, 0.2);
|
||||
}
|
||||
|
||||
.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);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.hero-day {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.75rem;
|
||||
line-height: 1;
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
.hero-detail { padding-left: var(--space-5); }
|
||||
|
|
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.hero-desc {
|
||||
color: var(--on-ink-body);
|
||||
color: rgba(232, 224, 208, 0.85);
|
||||
margin: 0 0 var(--space-3);
|
||||
max-width: 40rem;
|
||||
}
|
||||
.hero-meta {
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
display: flex;
|
||||
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-stat {
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
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-cta {
|
||||
background: var(--on-ink);
|
||||
background: var(--ink-text);
|
||||
color: var(--ink);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
|
|
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
.hero-cta:hover { opacity: 0.85; }
|
||||
|
||||
.hero-confirmed {
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
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;
|
||||
}
|
||||
.hero-change {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-ink-muted);
|
||||
color: var(--ink-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
|
|
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
display: flex;
|
||||
}
|
||||
.hero-empty-line {
|
||||
color: var(--on-ink);
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.25rem;
|
||||
margin: auto;
|
||||
|
|
@ -504,7 +504,6 @@ const heroAudience = hero?.audience ?? 'Members only';
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.hero-body { grid-template-columns: 1fr; }
|
||||
.hero-body::after { display: none; }
|
||||
.past-grid { grid-template-columns: 1fr; }
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.past-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -563,12 +563,4 @@ const innofoundarLogoExists = existsSync(join(process.cwd(), 'public/innofounder
|
|||
margin: 0;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -199,7 +199,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
.m-row { grid-template-columns: 52px 1fr; }
|
||||
.m-tags {
|
||||
grid-column: 1 / -1;
|
||||
|
|
|
|||
|
|
@ -421,13 +421,4 @@ const tiers = [
|
|||
margin: 0;
|
||||
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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,112 +1,212 @@
|
|||
---
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
|
||||
import RoadmapRoute from '../components/RoadmapRoute.astro';
|
||||
import { getAllRoadmapItems } from '../lib/db';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
const items = getAllRoadmapItems()
|
||||
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
|
||||
// Single-file roadmap — not a content collection
|
||||
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}>
|
||||
<article class="roadmap-page">
|
||||
<div class="page">
|
||||
|
||||
<!-- Single centred header — merges the page lead with the route's
|
||||
interaction hints. -->
|
||||
<header class="roadmap-header">
|
||||
<h1 class="roadmap-title">Roadmap</h1>
|
||||
<p class="roadmap-sub">
|
||||
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.
|
||||
<header class="page-header">
|
||||
<h1 class="display-md page-title">What we are building.</h1>
|
||||
<p class="subtitle">
|
||||
Three horizons. What is in progress now, what comes next,
|
||||
and what is further out. This is the live picture.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Legend lives above the route now — reads as a key the eye picks
|
||||
up just before walking the path. -->
|
||||
<div class="roadmap-legend" aria-label="Status legend">
|
||||
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
||||
<span><i style="background:#b96b58"></i>In beta</span>
|
||||
<span><i style="background:#5a6d83"></i>Planned</span>
|
||||
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
||||
<span><i style="background:#d4d2c8"></i>Considering</span>
|
||||
<div class="horizons">
|
||||
{sections.map((section) => (
|
||||
<section class="horizon">
|
||||
<div class="horizon-header">
|
||||
<span
|
||||
class="horizon-dot"
|
||||
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 class="headline-sm horizon-title">{section.title}</h2>
|
||||
</div>
|
||||
|
||||
<RoadmapRoute items={items} />
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.roadmap-page {
|
||||
padding: 0 36px 80px;
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Centred header ──────────────────────────────────────────── */
|
||||
.roadmap-header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
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;
|
||||
/* ── Header ──────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
/* ── Legend (above the route, key-style) ─────────────────────── */
|
||||
.roadmap-legend {
|
||||
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;
|
||||
.eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.roadmap-legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
.page-title {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Dispatch banner (foot of page, generous breathing room) ── */
|
||||
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
|
||||
.horizon-title {
|
||||
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) {
|
||||
.roadmap-page { padding: 0 24px 64px; }
|
||||
.roadmap-header { padding-top: 72px; margin-bottom: 40px; }
|
||||
.roadmap-title { font-size: 36px; }
|
||||
.roadmap-legend { margin-bottom: 12px; }
|
||||
.roadmap-page :global(.rr-dispatch) { margin-top: 72px; }
|
||||
/* ── Items ───────────────────────────────────────────────────────── */
|
||||
.item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -240,8 +240,4 @@ const next = sorted[currentIndex - 1] ?? null;
|
|||
.post-nav-link:hover .nav-title {
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -168,11 +168,4 @@ const updates = allUpdates.sort(
|
|||
color: var(--on-surface-variant);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -289,10 +289,4 @@ const user = Astro.locals.user;
|
|||
color: var(--on-surface-variant);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -232,27 +232,23 @@ a:hover {
|
|||
.ghost-border { border: var(--ghost-border); }
|
||||
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
||||
|
||||
/* --- Section link — black serif italic, underlined, larger.
|
||||
Placed at the bottom of its respective box or article.
|
||||
Italics are reserved for links + the Bifrost wordmark. --- */
|
||||
/* --- Section link — prominent italic serif, placed at the bottom of
|
||||
its respective box or article. See points 8 + 10 in the v3 spec:
|
||||
italics are reserved for links + the Bifrost wordmark. --- */
|
||||
.section-link {
|
||||
display: inline-block;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: var(--text-title-lg); /* 1.125rem — larger than body */
|
||||
color: var(--on-surface);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 0.5px;
|
||||
text-underline-offset: 4px;
|
||||
font-size: var(--text-body-md);
|
||||
color: var(--pigment-terracotta);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.section-link:hover {
|
||||
color: var(--on-surface);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
border-bottom: none;
|
||||
opacity: 0.78;
|
||||
color: var(--pigment-terracotta);
|
||||
}
|
||||
.section-link--ink {
|
||||
color: var(--ink-text);
|
||||
|
|
|
|||
|
|
@ -45,14 +45,8 @@
|
|||
--surface-card: #ffffff;
|
||||
--surface-card-border: rgba(0, 0, 0, 0.08);
|
||||
--ink: #2c3a52; /* deep indigo — membership card + event hero */
|
||||
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
|
||||
--ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */
|
||||
|
||||
/* --- 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 */
|
||||
--ink-text: #e8e0d0; /* readable cream on --ink */
|
||||
--ink-muted: #b8a989; /* muted label tone on --ink */
|
||||
|
||||
/* --- Semantic state mappings --- */
|
||||
--color-success: var(--pigment-copper);
|
||||
|
|
@ -71,14 +65,14 @@
|
|||
--text-display-md: clamp(2.5rem, 4vw, 3.5rem);
|
||||
--text-headline-lg: 2.25rem;
|
||||
--text-headline-md: 1.75rem;
|
||||
--text-headline-sm: 1.4375rem; /* 23px (was 22) */
|
||||
--text-title-lg: 1.1875rem; /* 19px (was 18) */
|
||||
--text-title-md: 1.0625rem; /* 17px (was 16) */
|
||||
--text-body-lg: 1.125rem; /* 18px (was 17) */
|
||||
--text-body-md: 1.0625rem; /* 17px (was 16) — base body */
|
||||
--text-body-sm: 0.9375rem; /* 15px (was 14) */
|
||||
--text-label-md: 0.875rem; /* 14px (was 13) */
|
||||
--text-label-sm: 0.8125rem; /* 13px (was 12) */
|
||||
--text-headline-sm: 1.375rem;
|
||||
--text-title-lg: 1.125rem;
|
||||
--text-title-md: 1rem;
|
||||
--text-body-lg: 1.0625rem;
|
||||
--text-body-md: 1rem;
|
||||
--text-body-sm: 0.875rem;
|
||||
--text-label-md: 0.8125rem;
|
||||
--text-label-sm: 0.75rem;
|
||||
|
||||
/* --- Tracking --- */
|
||||
--tracking-tight: -0.02em;
|
||||
|
|
@ -135,6 +129,6 @@
|
|||
--duration-slow: 420ms;
|
||||
|
||||
/* --- Layout --- */
|
||||
--content-max: 72rem; /* 1152px */
|
||||
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
|
||||
--reading-max: 42rem; /* 672px */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue