Compare commits
75 commits
66c3f6492f
...
5702859e37
| Author | SHA1 | Date | |
|---|---|---|---|
| 5702859e37 | |||
| 50d5922dcd | |||
| c509dc66ed | |||
| c9efe869ea | |||
| 3cf7171eb2 | |||
| b156f5b02b | |||
| 4c4df45f0c | |||
| 59842432bd | |||
| a520e8534e | |||
| 4aaf0957dd | |||
| 220f8e0290 | |||
| 8bbf8568f4 | |||
| 18d371b368 | |||
| e9a986d484 | |||
| dd9ea68fab | |||
| 3aaa21e6af | |||
| 09a10061b2 | |||
| cc9332e6e2 | |||
| dd7215d828 | |||
| ea056fff7b | |||
| 103bfa2f0c | |||
| 65191256ec | |||
| 0ea7e3fd96 | |||
| 407e7bc378 | |||
| 4009d5b711 | |||
| b2e0e8f518 | |||
| f90480bc8b | |||
| acbb722a0a | |||
| 22a55aa073 | |||
| 1c020f191c | |||
| 941d2a1557 | |||
| 1f95a6579d | |||
| d7c13d3c99 | |||
| 73dc656257 | |||
| 3917070dab | |||
| c0592f7ca5 | |||
| 8ca5e88618 | |||
| d85583b4a3 | |||
| 83503fe7a3 | |||
| b4df8e10f1 | |||
| b76e1fc5c4 | |||
| 788989fe35 | |||
| 1ec01a2257 | |||
| ac52e97c28 | |||
| f8d88ed760 | |||
| fde07b1f11 | |||
| 33a21735e6 | |||
| 1a169f3ac6 | |||
| 0fde7e493b | |||
| 16938026bc | |||
| 1325422056 | |||
| d49882b3f9 | |||
| 7bd4902b9d | |||
| 884cca85f1 | |||
| 66b460c35f | |||
| d17d9b93a7 | |||
| f659b70814 | |||
| 9c00087c50 | |||
| f8ecad4433 | |||
| 255ed76bbd | |||
| 7403d805cd | |||
| 39996ab93e | |||
| 5ddaad3da3 | |||
| 89688d605d | |||
| cde98f9454 | |||
| 7bd3997564 | |||
| 4219cda7b6 | |||
| 29fe1b7c92 | |||
| 096cdb00b6 | |||
| a4df2b4982 | |||
| 9ae8422527 | |||
| cb2efa70f3 | |||
| 9800d0a448 | |||
| 867661ee3d | |||
| cafbcf8b74 |
80 changed files with 8788 additions and 2595 deletions
|
|
@ -44,7 +44,24 @@
|
||||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
|
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
|
||||||
"Bash(pnpm db:seed)",
|
"Bash(pnpm db:seed)",
|
||||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
||||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
|
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)",
|
||||||
|
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
|
||||||
|
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
|
||||||
|
"Bash(awk -F: '{print $1}')",
|
||||||
|
"Bash(chmod +x /home/jonathan/.claude/statusline-command.sh)",
|
||||||
|
"Bash(bash -n /home/jonathan/.claude/statusline-command.sh)",
|
||||||
|
"Bash(command -v python3)",
|
||||||
|
"Bash(command -v python)",
|
||||||
|
"Bash(cd *)",
|
||||||
|
"Bash(break)",
|
||||||
|
"Bash(sed -n \"/export function createDispatch/,/^}/p\" src/lib/db.ts)",
|
||||||
|
"Bash(sed -n \"/export function dispatchSlug/,/^}/p\" src/lib/format.ts)",
|
||||||
|
"Bash(git check-ignore *)",
|
||||||
|
"Bash(sed -i -E 's/font-size: 15px/font-size: 16px/g; s/font-size: 14px/font-size: 15px/g; s/font-size: 13px/font-size: 14px/g; s/font-size: 12px/font-size: 13px/g; s/font-size: 11px/font-size: 12px/g; s/font-size: 10px/font-size: 11px/g; s/font-size: 9px/font-size: 10px/g' src/components/EventHeroCard.astro)",
|
||||||
|
"Bash(sed -n '/@media \\(max-width: 880px\\)/,/^ }/p' src/components/EventHeroCard.astro)",
|
||||||
|
"Bash(sed -n '1,80p' src/middleware.ts)",
|
||||||
|
"Bash(sed -n '1,80p' src/middleware/index.ts)",
|
||||||
|
"Bash(sed -n '1,40p' src/pages/api/join.ts)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ node_modules/
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
progress.md
|
progress.md
|
||||||
|
|
||||||
|
# Uploaded event photos (runtime, persists on the VPS)
|
||||||
|
data/uploads/
|
||||||
|
|
|
||||||
1
SPEC.md
1
SPEC.md
|
|
@ -255,6 +255,7 @@ Listed so Claude Code does not wander into them:
|
||||||
|
|
||||||
- Email sending (invites are copied manually; no transactional email provider)
|
- Email sending (invites are copied manually; no transactional email provider)
|
||||||
- File uploads from participants (they write text; Fenja attaches documents to meetings via git)
|
- File uploads from participants (they write text; Fenja attaches documents to meetings via git)
|
||||||
|
- *Exception (added v1):* Fenja admins may upload a single event photo (png/jpg) per event via the admin panel. Stored on the VPS and served by the app. This is the only upload path; participants still cannot upload.
|
||||||
- Rich-text editor beyond markdown-lite
|
- Rich-text editor beyond markdown-lite
|
||||||
- Threaded discussion on contributions (replies from Fenja only, one level deep)
|
- Threaded discussion on contributions (replies from Fenja only, one level deep)
|
||||||
- Dark mode
|
- Dark mode
|
||||||
|
|
|
||||||
10
migrations/0005_polls_on_dispatches.sql
Normal file
10
migrations/0005_polls_on_dispatches.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- 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;
|
||||||
40
migrations/0006_roadmap_considering.sql
Normal file
40
migrations/0006_roadmap_considering.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 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;
|
||||||
6
migrations/0007_roadmap_metadata.sql
Normal file
6
migrations/0007_roadmap_metadata.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 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;
|
||||||
39
migrations/0008_roadmap_planned.sql
Normal file
39
migrations/0008_roadmap_planned.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- 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,6 +61,9 @@ const newCabs = [
|
||||||
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
||||||
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
||||||
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
|
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
|
||||||
|
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
|
||||||
|
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
|
||||||
|
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertUser = db.prepare(`
|
const insertUser = db.prepare(`
|
||||||
|
|
@ -75,7 +78,15 @@ for (const c of newCabs) {
|
||||||
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
|
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
|
||||||
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
|
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
|
||||||
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
||||||
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
|
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 setCabMeta = db.prepare(`
|
const setCabMeta = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|
@ -108,6 +119,21 @@ const cabMeta = {
|
||||||
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
||||||
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
|
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
|
||||||
},
|
},
|
||||||
|
'mads@virk6.dk': {
|
||||||
|
title: 'Chief Strategy Officer',
|
||||||
|
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
|
||||||
|
focus_tags: ['Healthcare', 'Consent', 'Governance'],
|
||||||
|
},
|
||||||
|
'camilla@virk7.dk': {
|
||||||
|
title: 'Head of Cyber Resilience',
|
||||||
|
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
|
||||||
|
focus_tags: ['Defence', 'Resilience'],
|
||||||
|
},
|
||||||
|
'frederik@virk8.dk': {
|
||||||
|
title: 'Director of Public Innovation',
|
||||||
|
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
|
||||||
|
focus_tags: ['Public sector', 'Measurement'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const u of cabRows) {
|
for (const u of cabRows) {
|
||||||
|
|
@ -143,14 +169,17 @@ const nowIso = (offsetSeconds = 0) => {
|
||||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
|
// ── 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.
|
||||||
const pulseOptions = [
|
const pulseOptions = [
|
||||||
'Locking down on-prem deployment first',
|
'Locking down on-prem deployment first',
|
||||||
'Pushing the traceability layer to GA',
|
'Pushing the traceability layer to GA',
|
||||||
'Going wide on document ingestion',
|
'Going wide on document ingestion',
|
||||||
'Building the agentic query loop',
|
'Building the agentic query loop',
|
||||||
];
|
];
|
||||||
const pulseId = db.prepare(`
|
const decisionPulseId = db.prepare(`
|
||||||
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`).run(
|
`).run(
|
||||||
|
|
@ -160,27 +189,36 @@ const pulseId = db.prepare(`
|
||||||
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
||||||
).lastInsertRowid;
|
).lastInsertRowid;
|
||||||
|
|
||||||
// 2 votes from cabs[0] and cabs[1]
|
// 2 votes — Lars and Anna
|
||||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||||
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
||||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||||
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||||
|
|
||||||
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
|
// ── 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.
|
||||||
const roadmap = [
|
const roadmap = [
|
||||||
{ 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: '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 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: '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: '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: '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 and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
|
{ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertRoad = db.prepare(`
|
const insertRoad = db.prepare(`
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
VALUES (?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?)
|
||||||
`);
|
`);
|
||||||
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
||||||
for (const r of roadmap) {
|
for (const r of roadmap) {
|
||||||
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
|
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at, r.metadata_text).lastInsertRowid);
|
||||||
for (const uid of r.attributed) insertAttr.run(id, uid);
|
for (const uid of r.attributed) insertAttr.run(id, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +247,9 @@ for (const c of contribs) {
|
||||||
const dispatchSeed = [
|
const dispatchSeed = [
|
||||||
{ kind: 'decision', ageDays: 2,
|
{ kind: 'decision', ageDays: 2,
|
||||||
title: 'We are deprioritising public-cloud parity for Q3',
|
title: 'We are deprioritising public-cloud parity for Q3',
|
||||||
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
|
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.`,
|
||||||
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
|
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
|
||||||
|
|
||||||
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
|
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
|
||||||
|
|
@ -249,14 +289,17 @@ It is not a blog. It is the studio talking to the room — short, dated, signed.
|
||||||
|
|
||||||
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
|
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
|
||||||
const insertDispatch = db.prepare(`
|
const insertDispatch = db.prepare(`
|
||||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
|
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
|
||||||
VALUES (?,?,?,?,?,'published',?,?,?)
|
VALUES (?,?,?,?,?,'published',?,?,?,?)
|
||||||
`);
|
`);
|
||||||
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
||||||
const d = dispatchSeed[i];
|
const d = dispatchSeed[i];
|
||||||
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
||||||
const authorId = fenjas[i % fenjas.length].id;
|
const authorId = fenjas[i % fenjas.length].id;
|
||||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
||||||
|
|
@ -329,13 +372,13 @@ const insertActivity = db.prepare(`
|
||||||
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
||||||
VALUES (?,?,?,?,?)
|
VALUES (?,?,?,?,?)
|
||||||
`);
|
`);
|
||||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
|
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
|
||||||
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
|
||||||
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
|
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
|
||||||
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
||||||
|
|
||||||
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
|
console.log(` pulse #${decisionPulseId} open, 2 of ${cabs.length} voted`);
|
||||||
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
|
console.log(' roadmap: 9 items (2 shipping / 2 in_beta / 3 exploring / 2 considering)');
|
||||||
console.log(' contributions: 3 (most recent has 3 reactions)');
|
console.log(' contributions: 3 (most recent has 3 reactions)');
|
||||||
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
||||||
console.log(' events: dinner + studio hours + working session, 2 past');
|
console.log(' events: dinner + studio hours + working session, 2 past');
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
|
||||||
// schema's three statuses. In-progress items are actively being built and
|
// schema's three statuses. In-progress items are actively being built and
|
||||||
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
|
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
|
||||||
const SECTION_STATUS = {
|
const SECTION_STATUS = {
|
||||||
'In progress': { status: 'beta', target: null },
|
'In progress': { status: 'in_beta', target: null },
|
||||||
'Next': { status: 'exploring', target: 'Next quarter' },
|
'Next': { status: 'exploring', target: 'Next quarter' },
|
||||||
'Later': { status: 'exploring', target: 'Later this year' },
|
'Later': { status: 'exploring', target: 'Later this year' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1432
src/admin/admin.css
Normal file
1432
src/admin/admin.css
Normal file
File diff suppressed because it is too large
Load diff
121
src/admin/components/AdminLayout.astro
Normal file
121
src/admin/components/AdminLayout.astro
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* AdminLayout — the two-pane Backstage shell.
|
||||||
|
*
|
||||||
|
* Top strip: wordmark + " / Backstage" on the left, "Back to the portal"
|
||||||
|
* link on the right.
|
||||||
|
* Left: grouped resource sidebar with active-state and count badges.
|
||||||
|
* Right: slot for the current resource view (list or panel).
|
||||||
|
*
|
||||||
|
* Standalone from AppLayout deliberately — the member-facing portal and
|
||||||
|
* the admin surface have different chrome.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import '../admin.css';
|
||||||
|
import type { ResourceGroup, Resource } from '../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
/** Which resource is currently active — used for sidebar highlighting. */
|
||||||
|
activeResourceKey?: string;
|
||||||
|
/** The registry to render in the sidebar. */
|
||||||
|
groups: ResourceGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, activeResourceKey, groups } = Astro.props;
|
||||||
|
|
||||||
|
// Pre-compute list-counts and notify-counts for every registered resource,
|
||||||
|
// so the sidebar can render badges without doing async work in markup.
|
||||||
|
type SidebarEntry = {
|
||||||
|
resource: Resource;
|
||||||
|
count: number;
|
||||||
|
notify: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadEntries(group: ResourceGroup): Promise<SidebarEntry[]> {
|
||||||
|
return Promise.all(
|
||||||
|
group.resources.map(async (resource): Promise<SidebarEntry> => {
|
||||||
|
const items = await resource.list.queryFn();
|
||||||
|
const arr = Array.isArray(items) ? items : [];
|
||||||
|
return {
|
||||||
|
resource,
|
||||||
|
count: arr.length,
|
||||||
|
notify: resource.notifyCount ? resource.notifyCount.count(arr) : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedEntries = await Promise.all(
|
||||||
|
groups.map(async (g) => ({ group: g, entries: await loadEntries(g) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyResources = groupedEntries.some((g) => g.entries.length > 0);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title}>
|
||||||
|
<div class="backstage">
|
||||||
|
|
||||||
|
<!-- ── Top strip ──────────────────────────────────────────────── -->
|
||||||
|
<header class="bs-topbar" role="banner">
|
||||||
|
<a href="/admin" class="bs-brand" aria-label="Backstage — home">
|
||||||
|
<img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" />
|
||||||
|
<span class="bs-brand-sep" aria-hidden="true">·</span>
|
||||||
|
<span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span>
|
||||||
|
<span class="bs-brand-slash" aria-hidden="true">/</span>
|
||||||
|
<span class="bs-brand-backstage">Backstage</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/pulse" class="bs-back-link label-sm">← Back to the portal</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Two-pane body ──────────────────────────────────────────── -->
|
||||||
|
<div class="bs-body">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="bs-sidebar" aria-label="Resource navigation">
|
||||||
|
{hasAnyResources ? (
|
||||||
|
<ul class="bs-groups">
|
||||||
|
{groupedEntries.map(({ group, entries }) => (
|
||||||
|
entries.length > 0 && (
|
||||||
|
<li class="bs-group">
|
||||||
|
<p class="bs-group-label">{group.label}</p>
|
||||||
|
<ul class="bs-resources">
|
||||||
|
{entries.map(({ resource, count, notify }) => {
|
||||||
|
const isActive = activeResourceKey === resource.key;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`/admin/${resource.key}`}
|
||||||
|
class:list={['bs-resource', { active: isActive }]}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span class="bs-resource-label">{resource.label}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span class:list={['bs-count', { notify: notify > 0 }]}>
|
||||||
|
{notify > 0 ? notify : count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p class="bs-sidebar-empty">No resources registered yet.</p>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main pane -->
|
||||||
|
<main class="bs-main" role="main">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
59
src/admin/components/FieldRenderer.astro
Normal file
59
src/admin/components/FieldRenderer.astro
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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>
|
||||||
112
src/admin/components/ListCell.astro
Normal file
112
src/admin/components/ListCell.astro
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Internal cell renderer for ResourceListView.
|
||||||
|
*
|
||||||
|
* One Column kind per branch. Branches must stay exhaustive over Column<T>
|
||||||
|
* — if a new kind is added to resource-types.ts, TypeScript will warn here
|
||||||
|
* via the `never` fallback.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { relativeTime } from '../../lib/format';
|
||||||
|
import type { Column } from '../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
column: Column<Record<string, unknown>>;
|
||||||
|
item: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { column, item } = Astro.props;
|
||||||
|
const kind = column.kind ?? 'text';
|
||||||
|
|
||||||
|
// Text column — default rendering ─────────────────────────────────────────
|
||||||
|
let textTitle: string | null = null;
|
||||||
|
let textSubtitle: string | null = null;
|
||||||
|
if (kind === 'text') {
|
||||||
|
const col = column as Extract<typeof column, { kind?: 'text' }>;
|
||||||
|
if (col.render) {
|
||||||
|
const r = col.render(item);
|
||||||
|
textTitle = r.title;
|
||||||
|
textSubtitle = r.subtitle ?? null;
|
||||||
|
} else {
|
||||||
|
const v = item[column.key];
|
||||||
|
textTitle = v == null ? '' : String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pill column ─────────────────────────────────────────────────────────────
|
||||||
|
let pillLabel: string | null = null;
|
||||||
|
let pillClass: string | null = null;
|
||||||
|
if (kind === 'pill') {
|
||||||
|
const col = column as Extract<typeof column, { kind: 'pill' }>;
|
||||||
|
const raw = col.value ? col.value(item) : (item[column.key] as string | undefined);
|
||||||
|
if (raw) {
|
||||||
|
const variant = col.pillVariants[raw];
|
||||||
|
if (variant) {
|
||||||
|
pillLabel = variant.label;
|
||||||
|
pillClass = variant.class;
|
||||||
|
} else {
|
||||||
|
pillLabel = raw;
|
||||||
|
pillClass = 'pill-draft'; // graceful fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative-date column ────────────────────────────────────────────────────
|
||||||
|
let relText: string | null = null;
|
||||||
|
let relEmpty: string | null = null;
|
||||||
|
if (kind === 'relative-date') {
|
||||||
|
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
|
||||||
|
const raw = col.value ? col.value(item) : (item[column.key] as string | null | undefined);
|
||||||
|
if (raw) {
|
||||||
|
relText = relativeTime(raw);
|
||||||
|
} else {
|
||||||
|
relEmpty = col.emptyFallback ?? '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number column ───────────────────────────────────────────────────────────
|
||||||
|
let numberText: string | null = null;
|
||||||
|
if (kind === 'number') {
|
||||||
|
const col = column as Extract<typeof column, { kind: 'number' }>;
|
||||||
|
const raw = col.value ? col.value(item) : (item[column.key] as number | null | undefined);
|
||||||
|
numberText = raw == null ? '—' : String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag-list column ─────────────────────────────────────────────────────────
|
||||||
|
let tags: string[] = [];
|
||||||
|
if (kind === 'tag-list') {
|
||||||
|
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
|
||||||
|
tags = col.value(item);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
{kind === 'text' && (
|
||||||
|
<div class="bs-cell-text">
|
||||||
|
<span class:list={['bs-cell-title', { primary: column.primary }]}>{textTitle}</span>
|
||||||
|
{textSubtitle && <span class="bs-cell-subtitle">{textSubtitle}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'pill' && (
|
||||||
|
pillLabel
|
||||||
|
? <span class:list={['bs-pill', pillClass]}>{pillLabel}</span>
|
||||||
|
: <span class="bs-cell-empty">—</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'relative-date' && (
|
||||||
|
relText
|
||||||
|
? <span class="bs-cell-rel">{relText}</span>
|
||||||
|
: <span class="bs-cell-empty">{relEmpty}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'number' && (
|
||||||
|
<span class="bs-cell-number">{numberText}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'tag-list' && (
|
||||||
|
tags.length > 0
|
||||||
|
? <ul class="bs-cell-tags">
|
||||||
|
{tags.map(t => <li class="bs-tag">{t}</li>)}
|
||||||
|
</ul>
|
||||||
|
: <span class="bs-cell-empty">—</span>
|
||||||
|
)}
|
||||||
318
src/admin/components/ResourceEditPanel.astro
Normal file
318
src/admin/components/ResourceEditPanel.astro
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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>
|
||||||
211
src/admin/components/ResourceListView.astro
Normal file
211
src/admin/components/ResourceListView.astro
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* ResourceListView — shared list rendering for every Backstage resource.
|
||||||
|
*
|
||||||
|
* Reads URL state (?filter, ?q, ?page) and derives:
|
||||||
|
* - active filter (with isDefault fallback)
|
||||||
|
* - active column set (columnsByFilter override → columns)
|
||||||
|
* - filtered + searched + sorted + paginated row set
|
||||||
|
*
|
||||||
|
* Rows are full anchor elements pointing at ?edit=<id> so the table is
|
||||||
|
* fully keyboard-navigable and works without JS. The panel that consumes
|
||||||
|
* the edit param ships in step 6.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import ListCell from './ListCell.astro';
|
||||||
|
import type { Column, Resource, ResourceGroup } from '../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resource: Resource;
|
||||||
|
groups: ResourceGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resource, groups } = Astro.props;
|
||||||
|
const url = Astro.url;
|
||||||
|
|
||||||
|
// ── Resolve state from URL ────────────────────────────────────────────────
|
||||||
|
const filters = resource.list.filters ?? [];
|
||||||
|
const defaultFilterKey =
|
||||||
|
filters.find((f) => f.isDefault)?.key ?? filters[0]?.key ?? 'all';
|
||||||
|
const filterKey = url.searchParams.get('filter') ?? defaultFilterKey;
|
||||||
|
const activeFilter = filters.find((f) => f.key === filterKey);
|
||||||
|
|
||||||
|
const search = (url.searchParams.get('q') ?? '').trim();
|
||||||
|
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||||
|
const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
|
||||||
|
const pageSize = resource.list.pageSize ?? 25;
|
||||||
|
|
||||||
|
// ── Load + transform ──────────────────────────────────────────────────────
|
||||||
|
const queried = await resource.list.queryFn();
|
||||||
|
const allItems = (Array.isArray(queried) ? queried : []) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
const filtered = activeFilter
|
||||||
|
? allItems.filter((item) => activeFilter.predicate(item))
|
||||||
|
: allItems;
|
||||||
|
|
||||||
|
const searched = search && resource.list.search
|
||||||
|
? filtered.filter((item) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return resource.list.search!.fields.some((field) => {
|
||||||
|
const v = item[field as string];
|
||||||
|
return typeof v === 'string' && v.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: filtered;
|
||||||
|
|
||||||
|
const sort = resource.list.defaultSort;
|
||||||
|
const sorted = sort
|
||||||
|
? [...searched].sort((a, b) => {
|
||||||
|
const av = a[sort.key];
|
||||||
|
const bv = b[sort.key];
|
||||||
|
if (av === bv) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
const cmp = av < bv ? -1 : 1;
|
||||||
|
return sort.direction === 'desc' ? -cmp : cmp;
|
||||||
|
})
|
||||||
|
: searched;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||||
|
const page = Math.min(requestedPage, totalPages);
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const pageItems = sorted.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
// ── Resolve column set (columnsByFilter override → columns) ───────────────
|
||||||
|
const columns: Column<Record<string, unknown>>[] =
|
||||||
|
(resource.list.columnsByFilter && resource.list.columnsByFilter[filterKey]) ??
|
||||||
|
resource.list.columns;
|
||||||
|
|
||||||
|
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
|
||||||
|
|
||||||
|
// ── Group eyebrow ─────────────────────────────────────────────────────────
|
||||||
|
const group = groups.find((g) => g.key === resource.groupKey);
|
||||||
|
|
||||||
|
// ── Helper: build a query string preserving the other params ──────────────
|
||||||
|
function withParams(overrides: Record<string, string | number | null>): string {
|
||||||
|
const next = new URLSearchParams(url.searchParams);
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v == null) next.delete(k);
|
||||||
|
else next.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = next.toString();
|
||||||
|
return s ? `${url.pathname}?${s}` : url.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNewButton = resource.form !== null && resource.ops.create !== undefined;
|
||||||
|
const hasItems = allItems.length > 0;
|
||||||
|
const hasMatches = pageItems.length > 0;
|
||||||
|
// A row is only clickable when the panel has something to render — either an
|
||||||
|
// editable form or a review summary. Pure debug resources (activity) skip the
|
||||||
|
// anchor wrapper so clicks don't dirty the URL with a ?edit= that goes nowhere.
|
||||||
|
const rowsClickable = resource.form !== null || resource.summary !== undefined;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="bs-list">
|
||||||
|
<!-- ── Page header ─────────────────────────────────────────────── -->
|
||||||
|
<header class="bs-list-header">
|
||||||
|
<div class="bs-list-heading">
|
||||||
|
{group && <p class="bs-list-eyebrow">{group.label}</p>}
|
||||||
|
<h1 class="bs-list-title">{resource.pluralLabel}</h1>
|
||||||
|
{resource.description && <p class="bs-list-desc">{resource.description}</p>}
|
||||||
|
</div>
|
||||||
|
{showNewButton && (
|
||||||
|
<a href={withParams({ new: '1', edit: null })} class="bs-list-new label-sm">
|
||||||
|
+ New {resource.singularLabel.toLowerCase()}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Toolbar: search + filter chips ──────────────────────────── -->
|
||||||
|
{(resource.list.search || filters.length > 0) && (
|
||||||
|
<div class="bs-toolbar">
|
||||||
|
{resource.list.search && (
|
||||||
|
<form method="get" action={url.pathname} class="bs-search-form" role="search">
|
||||||
|
{/* Preserve the active filter when submitting a search */}
|
||||||
|
{filterKey !== defaultFilterKey && (
|
||||||
|
<input type="hidden" name="filter" value={filterKey} />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
class="bs-search-input"
|
||||||
|
placeholder={resource.list.search.placeholder}
|
||||||
|
value={search}
|
||||||
|
aria-label={resource.list.search.placeholder}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{filters.length > 0 && (
|
||||||
|
<div class="bs-filters" role="tablist" aria-label="Filter">
|
||||||
|
{filters.map((f) => {
|
||||||
|
const isActive = f.key === filterKey;
|
||||||
|
const href = withParams({ filter: f.key === defaultFilterKey ? null : f.key, page: null });
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
class:list={['bs-chip', { active: isActive }]}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- ── Grid table ──────────────────────────────────────────────── -->
|
||||||
|
{hasMatches ? (
|
||||||
|
<div class="bs-grid" role="table" style={`--bs-grid-cols: ${gridTemplate}`}>
|
||||||
|
<div class="bs-grid-head" role="row">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div class="bs-grid-th" role="columnheader">{col.label}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pageItems.map((item) => {
|
||||||
|
const id = Number(item.id);
|
||||||
|
const Tag = rowsClickable ? 'a' : 'div';
|
||||||
|
const linkProps = rowsClickable
|
||||||
|
? {
|
||||||
|
href: withParams({ edit: id, new: null }),
|
||||||
|
'aria-label': `Open ${resource.singularLabel.toLowerCase()} ${id}`,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
|
<Tag class="bs-grid-row" role="row" {...linkProps}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div class="bs-grid-td" role="cell">
|
||||||
|
<ListCell column={col} item={item} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p class="bs-list-empty">
|
||||||
|
{hasItems
|
||||||
|
? 'No items match the current filters.'
|
||||||
|
: `No ${resource.pluralLabel.toLowerCase()} yet.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- ── Pagination (only when paged) ────────────────────────────── -->
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav class="bs-pagination" aria-label="Pagination">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a class="bs-page-link" href={withParams({ page: page - 1 })}>← Previous</a>
|
||||||
|
) : (
|
||||||
|
<span class="bs-page-link disabled" aria-hidden="true">← Previous</span>
|
||||||
|
)}
|
||||||
|
<span class="bs-page-status">Page {page} of {totalPages}</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a class="bs-page-link" href={withParams({ page: page + 1 })}>Next →</a>
|
||||||
|
) : (
|
||||||
|
<span class="bs-page-link disabled" aria-hidden="true">Next →</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
29
src/admin/components/fields/DateField.astro
Normal file
29
src/admin/components/fields/DateField.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
import type { DateField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: DateField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
|
||||||
|
// Coerce ISO datetime → "YYYY-MM-DD" for the <input type="date"> control.
|
||||||
|
function toDateInputValue(v: unknown): string {
|
||||||
|
if (v == null || v === '') return '';
|
||||||
|
const s = String(v);
|
||||||
|
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
const v = toDateInputValue(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input"
|
||||||
|
value={v}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
/>
|
||||||
30
src/admin/components/fields/DatetimeField.astro
Normal file
30
src/admin/components/fields/DatetimeField.astro
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
import type { DatetimeField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: DatetimeField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
|
||||||
|
// Coerce ISO datetime (which may be "YYYY-MM-DD HH:MM:SS" SQLite-style or
|
||||||
|
// "YYYY-MM-DDTHH:mm:ss[Z|+offset]") to the "YYYY-MM-DDTHH:mm" the input wants.
|
||||||
|
function toDatetimeLocal(v: unknown): string {
|
||||||
|
if (v == null || v === '') return '';
|
||||||
|
const s = String(v).replace(' ', 'T');
|
||||||
|
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
const v = toDatetimeLocal(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input"
|
||||||
|
value={v}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
/>
|
||||||
134
src/admin/components/fields/ImageUploadField.astro
Normal file
134
src/admin/components/fields/ImageUploadField.astro
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
41
src/admin/components/fields/MarkdownField.astro
Normal file
41
src/admin/components/fields/MarkdownField.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* MarkdownField — textarea with a Preview toggle.
|
||||||
|
*
|
||||||
|
* The preview panel is rendered server-side once with the current value, so
|
||||||
|
* it's available the moment the toggle flips even without a network call.
|
||||||
|
* Toggling on does NOT re-render — that's a Phase 2 enhancement. The toggle
|
||||||
|
* itself is keyboard-accessible.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import { renderMd } from '../../../lib/markdown';
|
||||||
|
import type { MarkdownField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: MarkdownField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const v = value == null ? '' : String(value);
|
||||||
|
const previewHtml = v ? renderMd(v) : '<p class="bs-md-empty">Nothing to preview yet.</p>';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="bs-md" data-md-field={field.key}>
|
||||||
|
<div class="bs-md-toolbar">
|
||||||
|
<button type="button" class="bs-md-tab is-active" data-md-mode="edit">Write</button>
|
||||||
|
<button type="button" class="bs-md-tab" data-md-mode="preview">Preview</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input bs-textarea bs-md-input"
|
||||||
|
rows={field.rows ?? 14}
|
||||||
|
maxlength={field.maxLength}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
>{v}</textarea>
|
||||||
|
|
||||||
|
<div class="bs-md-preview" hidden set:html={previewHtml} />
|
||||||
|
</div>
|
||||||
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal file
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* MultiSelectAsyncField — checkbox grid for picking multiple options.
|
||||||
|
*
|
||||||
|
* Submits as repeated form values under field.key[] (browser default for
|
||||||
|
* multiple checkboxes with the same name). The route handler in step 7
|
||||||
|
* normalises the array form via getAll().
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import type { MultiSelectAsyncField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: MultiSelectAsyncField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const options = await field.loadOptions();
|
||||||
|
|
||||||
|
const selected = new Set<string>();
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) selected.add(String(v));
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<fieldset class="bs-multiselect" disabled={field.readOnly}>
|
||||||
|
<legend class="bs-visually-hidden">{field.label}</legend>
|
||||||
|
{options.length === 0 && (
|
||||||
|
<p class="bs-multiselect-empty">No options available.</p>
|
||||||
|
)}
|
||||||
|
{options.map(opt => {
|
||||||
|
const id = `f-${field.key}-${opt.value}`;
|
||||||
|
const isChecked = selected.has(String(opt.value));
|
||||||
|
return (
|
||||||
|
<label class="bs-multiselect-row" for={id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={field.key}
|
||||||
|
value={String(opt.value)}
|
||||||
|
checked={isChecked}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
53
src/admin/components/fields/MultiTextField.astro
Normal file
53
src/admin/components/fields/MultiTextField.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* MultiTextField — N text inputs with add/remove, used for pulse options.
|
||||||
|
*
|
||||||
|
* Submits as repeated form values under field.key. Initial input count is
|
||||||
|
* Math.max(minItems, value.length). Add/remove buttons are managed by a
|
||||||
|
* small client script attached to the panel.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import type { MultiTextField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: MultiTextField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const min = field.minItems ?? 1;
|
||||||
|
const max = field.maxItems ?? 10;
|
||||||
|
|
||||||
|
const initialValues: string[] = Array.isArray(value)
|
||||||
|
? value.map((v) => (v == null ? '' : String(v)))
|
||||||
|
: [];
|
||||||
|
const initialCount = Math.max(min, initialValues.length, 1);
|
||||||
|
while (initialValues.length < initialCount) initialValues.push('');
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bs-multitext"
|
||||||
|
data-multitext={field.key}
|
||||||
|
data-min={min}
|
||||||
|
data-max={max}
|
||||||
|
>
|
||||||
|
<div class="bs-multitext-rows">
|
||||||
|
{initialValues.map((v, i) => (
|
||||||
|
<div class="bs-multitext-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input"
|
||||||
|
value={v}
|
||||||
|
placeholder={field.placeholderEach ?? `Option ${i + 1}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bs-multitext-remove"
|
||||||
|
aria-label="Remove option"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="bs-multitext-add">+ Add option</button>
|
||||||
|
</div>
|
||||||
24
src/admin/components/fields/NumberField.astro
Normal file
24
src/admin/components/fields/NumberField.astro
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
import type { NumberField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: NumberField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const v = value == null ? '' : String(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input"
|
||||||
|
value={v}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
/>
|
||||||
14
src/admin/components/fields/ReadonlyField.astro
Normal file
14
src/admin/components/fields/ReadonlyField.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
import type { ReadonlyField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: ReadonlyField;
|
||||||
|
value: unknown;
|
||||||
|
item: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value, item } = Astro.props;
|
||||||
|
const display = field.render ? field.render(value, item) : (value == null ? '—' : String(value));
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="bs-readonly" id={`f-${field.key}`}>{display}</div>
|
||||||
25
src/admin/components/fields/SelectAsyncField.astro
Normal file
25
src/admin/components/fields/SelectAsyncField.astro
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
import type { SelectAsyncField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: SelectAsyncField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const current = value == null ? '' : String(value);
|
||||||
|
const options = await field.loadOptions();
|
||||||
|
---
|
||||||
|
|
||||||
|
<select
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input bs-select"
|
||||||
|
required={field.required}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
>
|
||||||
|
{!field.required && <option value="">—</option>}
|
||||||
|
{options.map(opt => (
|
||||||
|
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
24
src/admin/components/fields/SelectField.astro
Normal file
24
src/admin/components/fields/SelectField.astro
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
import type { SelectField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: SelectField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const current = value == null ? '' : String(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<select
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input bs-select"
|
||||||
|
required={field.required}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
>
|
||||||
|
{!field.required && <option value="">—</option>}
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
23
src/admin/components/fields/TextField.astro
Normal file
23
src/admin/components/fields/TextField.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
import type { TextField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: TextField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const v = value == null ? '' : String(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input"
|
||||||
|
value={v}
|
||||||
|
placeholder={field.placeholder ?? ''}
|
||||||
|
maxlength={field.maxLength}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
/>
|
||||||
22
src/admin/components/fields/TextareaField.astro
Normal file
22
src/admin/components/fields/TextareaField.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
import type { TextareaField } from '../../resource-types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: TextareaField;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { field, value } = Astro.props;
|
||||||
|
const v = value == null ? '' : String(value);
|
||||||
|
---
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id={`f-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
class="bs-input bs-textarea"
|
||||||
|
rows={field.rows ?? 4}
|
||||||
|
placeholder={field.placeholder ?? ''}
|
||||||
|
maxlength={field.maxLength}
|
||||||
|
required={field.required}
|
||||||
|
readonly={field.readOnly}
|
||||||
|
>{v}</textarea>
|
||||||
93
src/admin/embeds/PulseSubForm.astro
Normal file
93
src/admin/embeds/PulseSubForm.astro
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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>
|
||||||
323
src/admin/resource-types.ts
Normal file
323
src/admin/resource-types.ts
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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>[];
|
||||||
|
}
|
||||||
102
src/admin/resources/activity.ts
Normal file
102
src/admin/resources/activity.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Activity resource — read-only debug feed.
|
||||||
|
*
|
||||||
|
* Activity rows are emitted by side effects elsewhere in the app (voting,
|
||||||
|
* RSVPs, roadmap-ship transitions, pulse opens). The admin view is a tail
|
||||||
|
* of the table for monitoring; no create, no edit, no delete.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllActivityForAdmin,
|
||||||
|
type ActivityKind,
|
||||||
|
type ActivityRow,
|
||||||
|
} from '../../lib/db';
|
||||||
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<ActivityKind, string> = {
|
||||||
|
voted: 'Voted',
|
||||||
|
rsvped: 'RSVPed',
|
||||||
|
booked_office_hours: 'Booked office hours',
|
||||||
|
roadmap_shipped: 'Roadmap shipped',
|
||||||
|
pulse_opened: 'Pulse opened',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_PILL_CLASS: Record<ActivityKind, string> = {
|
||||||
|
voted: 'pill-update',
|
||||||
|
rsvped: 'pill-published',
|
||||||
|
booked_office_hours: 'pill-bts',
|
||||||
|
roadmap_shipped: 'pill-shipping',
|
||||||
|
pulse_opened: 'pill-pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const activityResource: Resource<ActivityRow> = {
|
||||||
|
key: 'activity',
|
||||||
|
label: 'Activity',
|
||||||
|
pluralLabel: 'Activity',
|
||||||
|
singularLabel: 'Event',
|
||||||
|
groupKey: 'system',
|
||||||
|
description: 'Recent member actions: votes, RSVPs, office-hour bookings, pulse opens, roadmap ships.',
|
||||||
|
|
||||||
|
list: {
|
||||||
|
queryFn: () => getAllActivityForAdmin(200),
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'actor_name',
|
||||||
|
label: 'Actor',
|
||||||
|
primary: true,
|
||||||
|
width: '1.5fr',
|
||||||
|
render: (item) => ({
|
||||||
|
title: item.actor_name,
|
||||||
|
subtitle: item.actor_role,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
label: 'Kind',
|
||||||
|
kind: 'pill',
|
||||||
|
width: '160px',
|
||||||
|
pillVariants: Object.fromEntries(
|
||||||
|
(Object.keys(KIND_LABEL) as ActivityKind[]).map((k) => [
|
||||||
|
k,
|
||||||
|
{ label: KIND_LABEL[k], class: KIND_PILL_CLASS[k] },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'subject',
|
||||||
|
label: 'Subject',
|
||||||
|
width: '1fr',
|
||||||
|
render: (item) => ({
|
||||||
|
title: `${item.subject_type} #${item.subject_id}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'When',
|
||||||
|
kind: 'relative-date',
|
||||||
|
width: '120px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||||
|
{
|
||||||
|
key: 'last_7_days',
|
||||||
|
label: 'Last 7 days',
|
||||||
|
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 7 * DAY_MS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'last_30_days',
|
||||||
|
label: 'Last 30 days',
|
||||||
|
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 30 * DAY_MS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||||
|
pageSize: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pure read view — no form, no summary, no ops, no actions.
|
||||||
|
form: null,
|
||||||
|
ops: {},
|
||||||
|
};
|
||||||
264
src/admin/resources/dispatches.ts
Normal file
264
src/admin/resources/dispatches.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
273
src/admin/resources/events.ts
Normal file
273
src/admin/resources/events.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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 };
|
||||||
33
src/admin/resources/index.ts
Normal file
33
src/admin/resources/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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],
|
||||||
|
},
|
||||||
|
];
|
||||||
192
src/admin/resources/invitations.ts
Normal file
192
src/admin/resources/invitations.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Invitations resource.
|
||||||
|
*
|
||||||
|
* Create surfaces the magic link via ctx.result.invite-link → the route
|
||||||
|
* handler propagates it as ?invite_url=... and the edit panel renders a
|
||||||
|
* copy-to-clipboard block. The token itself is never stored — only its
|
||||||
|
* hash — so the link is shown exactly once.
|
||||||
|
*
|
||||||
|
* "Revoke" is implemented as an action (sets expires_at = now()), not as
|
||||||
|
* ops.delete, because the row stays in the table for audit history.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInvite,
|
||||||
|
getAllInvites,
|
||||||
|
getInviteById,
|
||||||
|
revokeInvite,
|
||||||
|
type Invite,
|
||||||
|
type Role,
|
||||||
|
} from '../../lib/db';
|
||||||
|
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||||
|
import { relativeTime } from '../../lib/format';
|
||||||
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
|
type InviteRow = Invite & { creator_name: string | null };
|
||||||
|
|
||||||
|
function deriveStatus(item: InviteRow): 'pending' | 'accepted' | 'expired' {
|
||||||
|
if (item.used_at) return 'accepted';
|
||||||
|
if (new Date(item.expires_at).getTime() < Date.now()) return 'expired';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invitationsResource: Resource<InviteRow> = {
|
||||||
|
key: 'invitations',
|
||||||
|
label: 'Invitations',
|
||||||
|
pluralLabel: 'Invitations',
|
||||||
|
singularLabel: 'Invitation',
|
||||||
|
groupKey: 'council',
|
||||||
|
description: 'Magic links sent to new pilots and council members. Tokens are shown once on create.',
|
||||||
|
|
||||||
|
list: {
|
||||||
|
queryFn: () => getAllInvites(),
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
primary: true,
|
||||||
|
width: '2fr',
|
||||||
|
render: (item) => ({
|
||||||
|
title: item.email,
|
||||||
|
subtitle: `${item.name} · ${item.organisation || '—'}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: 'Role',
|
||||||
|
kind: 'pill',
|
||||||
|
width: '110px',
|
||||||
|
pillVariants: {
|
||||||
|
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||||||
|
cab: { label: 'Council', class: 'pill-cab' },
|
||||||
|
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'creator_name',
|
||||||
|
label: 'Invited by',
|
||||||
|
width: '140px',
|
||||||
|
render: (item) => ({ title: item.creator_name || '—' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'Created',
|
||||||
|
kind: 'relative-date',
|
||||||
|
width: '110px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
kind: 'pill',
|
||||||
|
width: '110px',
|
||||||
|
value: (item) => deriveStatus(item),
|
||||||
|
pillVariants: {
|
||||||
|
pending: { label: 'Pending', class: 'pill-pending' },
|
||||||
|
accepted: { label: 'Accepted', class: 'pill-accepted' },
|
||||||
|
expired: { label: 'Expired', class: 'pill-expired' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||||
|
{ key: 'pending', label: 'Pending', predicate: (i) => deriveStatus(i) === 'pending' },
|
||||||
|
{ key: 'accepted', label: 'Accepted', predicate: (i) => deriveStatus(i) === 'accepted' },
|
||||||
|
{ key: 'expired', label: 'Expired', predicate: (i) => deriveStatus(i) === 'expired' },
|
||||||
|
],
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search by email or name…',
|
||||||
|
fields: ['email', 'name', 'organisation'],
|
||||||
|
},
|
||||||
|
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||||
|
pageSize: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
kind: 'text',
|
||||||
|
required: true,
|
||||||
|
maxLength: 240,
|
||||||
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
helperText: 'Where the magic link will land. Cannot be changed later.',
|
||||||
|
},
|
||||||
|
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
|
||||||
|
{ key: 'organisation', label: 'Organisation', kind: 'text', maxLength: 200 },
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: 'Role',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'pilot', label: 'Pilot' },
|
||||||
|
{ value: 'cab', label: 'Council' },
|
||||||
|
],
|
||||||
|
defaultValue: 'pilot',
|
||||||
|
helperText: 'Council invites allocate a member number on signup.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Existing invites are immutable — the panel renders the summary +
|
||||||
|
// Revoke action via the review-mode pathway. Create still uses the form.
|
||||||
|
summary: (item) => {
|
||||||
|
const status = deriveStatus(item);
|
||||||
|
return [
|
||||||
|
{ label: 'Email', value: item.email },
|
||||||
|
{ label: 'Name', value: item.name },
|
||||||
|
{ label: 'Organisation', value: item.organisation || '—' },
|
||||||
|
{
|
||||||
|
label: 'Role',
|
||||||
|
value: item.role === 'cab' ? 'Council' : item.role === 'pilot' ? 'Pilot' : 'Fenja team',
|
||||||
|
},
|
||||||
|
{ label: 'Status', value: status === 'pending' ? 'Pending' : status === 'accepted' ? 'Accepted' : 'Expired' },
|
||||||
|
{ label: 'Invited by', value: item.creator_name ?? '—' },
|
||||||
|
{ label: 'Created', value: relativeTime(item.created_at) },
|
||||||
|
{ label: 'Expires', value: fmtDateTime(item.expires_at) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
ops: {
|
||||||
|
getById: (id) => getInviteById(id),
|
||||||
|
|
||||||
|
create: (data, ctx) => {
|
||||||
|
const { token, tokenHash } = generateInviteToken();
|
||||||
|
const id = createInvite({
|
||||||
|
token_hash: tokenHash,
|
||||||
|
email: String(data.email).trim().toLowerCase(),
|
||||||
|
name: String(data.name).trim(),
|
||||||
|
organisation: ((data.organisation as string) ?? '').trim(),
|
||||||
|
role: data.role as Role,
|
||||||
|
expires_at: inviteExpiresAt(),
|
||||||
|
created_by_user_id: ctx.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Surface the one-shot magic link via the result mechanism — the route
|
||||||
|
// handler propagates it as ?invite_url= and the panel renders a copy
|
||||||
|
// block on the next page load. 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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
110
src/admin/resources/join-requests.ts
Normal file
110
src/admin/resources/join-requests.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Join requests resource — read-only review surface.
|
||||||
|
*
|
||||||
|
* The existing data model: a pilot user (already in the system) requests
|
||||||
|
* promotion to council. The request joins to users for name/email/org;
|
||||||
|
* there's no separate "stranger sign-up" model. As a result, the approval
|
||||||
|
* flow upgrades the existing user's role rather than minting a fresh invite.
|
||||||
|
*
|
||||||
|
* Deviation from the original delta: no approve_as_pilot action (the
|
||||||
|
* requester is already a pilot) and no invite-link result (the user already
|
||||||
|
* exists). Stranger sign-ups would require a schema change to the
|
||||||
|
* join_requests table — left for a future follow-up.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllJoinRequests,
|
||||||
|
getJoinRequestById,
|
||||||
|
deleteJoinRequest,
|
||||||
|
updateUserRole,
|
||||||
|
type JoinRequest,
|
||||||
|
} from '../../lib/db';
|
||||||
|
import { relativeTime } from '../../lib/format';
|
||||||
|
import type { Resource } from '../resource-types';
|
||||||
|
|
||||||
|
export const joinRequestsResource: Resource<JoinRequest> = {
|
||||||
|
key: 'join_requests',
|
||||||
|
label: 'Join requests',
|
||||||
|
pluralLabel: 'Join requests',
|
||||||
|
singularLabel: 'Join request',
|
||||||
|
groupKey: 'council',
|
||||||
|
description: 'Pilots asking to be upgraded to council. Approve to grant access, decline to dismiss.',
|
||||||
|
|
||||||
|
list: {
|
||||||
|
queryFn: () => getAllJoinRequests(),
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
key: 'user_name',
|
||||||
|
label: 'Name',
|
||||||
|
primary: true,
|
||||||
|
width: '2fr',
|
||||||
|
render: (item) => ({ title: item.user_name, subtitle: item.user_email }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user_organisation',
|
||||||
|
label: 'Organisation',
|
||||||
|
width: '1.5fr',
|
||||||
|
render: (item) => ({ title: item.user_organisation || '—' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'Requested',
|
||||||
|
kind: 'relative-date',
|
||||||
|
width: '120px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
search: {
|
||||||
|
placeholder: 'Search by name, email, organisation…',
|
||||||
|
fields: ['user_name', 'user_email', 'user_organisation'],
|
||||||
|
},
|
||||||
|
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||||
|
pageSize: 50,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Read-only: no edit form, no create flow.
|
||||||
|
form: null,
|
||||||
|
|
||||||
|
// Notify count = total pending requests (everything in the table is
|
||||||
|
// pending under the current model — there's no status column yet).
|
||||||
|
notifyCount: {
|
||||||
|
count: (items) => items.length,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Review panel summary — shown when an item is clicked.
|
||||||
|
summary: (item) => [
|
||||||
|
{ label: 'Name', value: item.user_name },
|
||||||
|
{ label: 'Email', value: item.user_email },
|
||||||
|
{ label: 'Organisation', value: item.user_organisation || '—' },
|
||||||
|
{ label: 'Requested', value: relativeTime(item.created_at) },
|
||||||
|
],
|
||||||
|
|
||||||
|
ops: {
|
||||||
|
getById: (id) => getJoinRequestById(id),
|
||||||
|
// No delete in ops — declining is an action below, so the destructive
|
||||||
|
// intent is named explicitly in the panel.
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
key: 'approve_as_cab',
|
||||||
|
label: 'Approve as council',
|
||||||
|
confirmText:
|
||||||
|
'Promote this pilot to council? They will gain access to council-only surfaces.',
|
||||||
|
handler: (id) => {
|
||||||
|
const req = getJoinRequestById(id);
|
||||||
|
if (!req) return;
|
||||||
|
updateUserRole(req.user_id, 'cab');
|
||||||
|
deleteJoinRequest(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'decline',
|
||||||
|
label: 'Decline',
|
||||||
|
destructive: true,
|
||||||
|
confirmText: 'Decline this request? It will be removed from the queue.',
|
||||||
|
handler: (id) => {
|
||||||
|
deleteJoinRequest(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
188
src/admin/resources/roadmap.ts
Normal file
188
src/admin/resources/roadmap.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
340
src/admin/resources/users.ts
Normal file
340
src/admin/resources/users.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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 };
|
||||||
124
src/admin/validate.ts
Normal file
124
src/admin/validate.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/components/EventHeroCard.astro
Normal file
370
src/components/EventHeroCard.astro
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
208
src/components/LatestDispatchBanner.astro
Normal file
208
src/components/LatestDispatchBanner.astro
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
---
|
||||||
|
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>
|
<style>
|
||||||
.m-card {
|
.m-card {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--ink-text);
|
background: var(--on-ink);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -78,7 +78,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-name {
|
.m-name {
|
||||||
|
|
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
.m-since-value {
|
.m-since-value {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-tags {
|
.m-tags {
|
||||||
|
|
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.m-tag {
|
.m-tag {
|
||||||
border: 0.5px solid rgba(232, 224, 208, 0.3);
|
border: 0.5px solid rgba(255, 252, 247, 0.3);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
|
||||||
264
src/components/RoadmapCarousel.astro
Normal file
264
src/components/RoadmapCarousel.astro
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
778
src/components/RoadmapRoute.astro
Normal file
778
src/components/RoadmapRoute.astro
Normal file
|
|
@ -0,0 +1,778 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
---
|
|
||||||
import type { ActivityRow } from '../../lib/db';
|
|
||||||
import { fmtDateTime } from '../../lib/markdown';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rows: ActivityRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows } = Astro.props;
|
|
||||||
---
|
|
||||||
<div class="tab-content">
|
|
||||||
<section class="section">
|
|
||||||
<h2 class="label-sm section-heading">Recent activity</h2>
|
|
||||||
<p class="body-sm section-note">
|
|
||||||
The raw activity feed — what powers the ticker on /pulse. Read-only debug view.
|
|
||||||
Showing up to 200 most-recent events; the ticker takes the last 12 within 7 days.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<p class="body-sm empty-msg">No activity recorded yet.</p>
|
|
||||||
) : (
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="label-sm">When</th>
|
|
||||||
<th class="label-sm">Actor</th>
|
|
||||||
<th class="label-sm">Kind</th>
|
|
||||||
<th class="label-sm">Subject</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map(r => (
|
|
||||||
<tr>
|
|
||||||
<td class="body-sm muted">{fmtDateTime(r.created_at)}</td>
|
|
||||||
<td class="body-sm">{r.actor_name} <span class="muted">({r.actor_role})</span></td>
|
|
||||||
<td class="body-sm" style="text-transform:lowercase">{r.kind}</td>
|
|
||||||
<td class="body-sm muted">{r.subject_type} #{r.subject_id}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
---
|
|
||||||
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>
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
---
|
|
||||||
import type { Event } from '../../lib/db';
|
|
||||||
import { fmtDateTime } from '../../lib/markdown';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
events: Event[];
|
|
||||||
editing: Event | null;
|
|
||||||
viewing: Event | null;
|
|
||||||
viewingRsvps: { going: number; interested: number; declined: number } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { events, editing, viewing, viewingRsvps } = Astro.props;
|
|
||||||
|
|
||||||
const KIND_LABEL = {
|
|
||||||
dinner: 'Dinner',
|
|
||||||
office_hours: 'Studio hours',
|
|
||||||
summit: 'Summit',
|
|
||||||
virtual: 'Virtual',
|
|
||||||
working_session: 'Working session',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function toInputValue(sql: string | null | undefined): string {
|
|
||||||
if (!sql) return '';
|
|
||||||
return sql.replace(' ', 'T').slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formAction = editing ? 'update_event' : 'create_event';
|
|
||||||
---
|
|
||||||
<div class="tab-content">
|
|
||||||
|
|
||||||
{viewing && viewingRsvps ? (
|
|
||||||
<section class="section">
|
|
||||||
<a href="/admin?tab=events" class="back-link label-sm">← Back to events</a>
|
|
||||||
<h2 class="label-sm section-heading">RSVPs — {viewing.title}</h2>
|
|
||||||
<p class="body-sm muted">{fmtDateTime(viewing.starts_at)} · {viewing.location}</p>
|
|
||||||
<dl class="rsvp-summary">
|
|
||||||
<div><dt class="label-sm">Going</dt><dd class="rsvp-count">{viewingRsvps.going}</dd></div>
|
|
||||||
<div><dt class="label-sm">Interested</dt><dd class="rsvp-count">{viewingRsvps.interested}</dd></div>
|
|
||||||
<div><dt class="label-sm">Declined</dt><dd class="rsvp-count">{viewingRsvps.declined}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<section class="section">
|
|
||||||
<h2 class="label-sm section-heading">{editing ? 'Edit event' : 'New event'}</h2>
|
|
||||||
<form method="POST" class="invite-form" novalidate>
|
|
||||||
<input type="hidden" name="action" value={formAction} />
|
|
||||||
{editing && <input type="hidden" name="event_id" value={editing.id} />}
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="title" class="label-sm field-label">Title</label>
|
|
||||||
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="slug" class="label-sm field-label">Slug (URL)</label>
|
|
||||||
<input type="text" id="slug" name="slug" class="input body-md" required value={editing?.slug ?? ''} readonly={!!editing} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="kind" class="label-sm field-label">Kind</label>
|
|
||||||
<select id="kind" name="kind" class="select body-md" required>
|
|
||||||
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
|
|
||||||
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
|
|
||||||
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
|
|
||||||
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
|
|
||||||
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="location" class="label-sm field-label">Location</label>
|
|
||||||
<input type="text" id="location" name="location" class="input body-md" value={editing?.location ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="starts_at" class="label-sm field-label">Starts at (UTC)</label>
|
|
||||||
<input type="datetime-local" id="starts_at" name="starts_at" class="input body-md" required value={toInputValue(editing?.starts_at)} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ends_at" class="label-sm field-label">Ends at (optional)</label>
|
|
||||||
<input type="datetime-local" id="ends_at" name="ends_at" class="input body-md" value={toInputValue(editing?.ends_at)} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="capacity" class="label-sm field-label">Capacity (optional)</label>
|
|
||||||
<input type="number" id="capacity" name="capacity" class="input body-md" value={editing?.capacity ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
|
|
||||||
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
|
|
||||||
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="duration_label" class="label-sm field-label">Duration label</label>
|
|
||||||
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
|
|
||||||
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
|
|
||||||
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="description" class="label-sm field-label">Description</label>
|
|
||||||
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create event'}</button>
|
|
||||||
{editing && <a href="/admin?tab=events" class="action-link label-sm">Cancel</a>}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<h2 class="label-sm section-heading">All events</h2>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<p class="body-sm empty-msg">No events yet.</p>
|
|
||||||
) : (
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="label-sm">Title</th>
|
|
||||||
<th class="label-sm">Kind</th>
|
|
||||||
<th class="label-sm">When</th>
|
|
||||||
<th class="label-sm">Location</th>
|
|
||||||
<th class="label-sm">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{events.map(ev => (
|
|
||||||
<tr>
|
|
||||||
<td class="body-sm">{ev.title}</td>
|
|
||||||
<td class="body-sm muted">{KIND_LABEL[ev.kind]}</td>
|
|
||||||
<td class="body-sm muted">{fmtDateTime(ev.starts_at)}</td>
|
|
||||||
<td class="body-sm muted">{ev.location || '—'}</td>
|
|
||||||
<td class="action-cell">
|
|
||||||
<a href={`/admin?tab=events&view=${ev.id}`} class="action-link label-sm">RSVPs</a>
|
|
||||||
<a href={`/admin?tab=events&edit=${ev.id}`} class="action-link label-sm">Edit</a>
|
|
||||||
<form method="POST" class="inline-form">
|
|
||||||
<input type="hidden" name="action" value="delete_event" />
|
|
||||||
<input type="hidden" name="event_id" value={ev.id} />
|
|
||||||
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this event?')">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.back-link {
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: none;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
|
||||||
|
|
||||||
.rsvp-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-6);
|
|
||||||
margin: var(--space-4) 0 0;
|
|
||||||
}
|
|
||||||
.rsvp-summary div {
|
|
||||||
background: var(--surface-container-low);
|
|
||||||
padding: var(--space-5);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.rsvp-summary dt {
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
margin: 0 0 var(--space-2) 0;
|
|
||||||
}
|
|
||||||
.rsvp-summary dd {
|
|
||||||
font-family: var(--font-serif);
|
|
||||||
font-size: 2.5rem;
|
|
||||||
color: var(--on-surface);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
|
||||||
|
|
||||||
.action-cell {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.action-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: none;
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
transition: color var(--duration-fast) var(--ease-standard);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
---
|
|
||||||
import type { PulseRow, PulseWithCounts } from '../../lib/db';
|
|
||||||
import { fmtDateTime } from '../../lib/markdown';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pulses: PulseRow[];
|
|
||||||
editing: PulseRow | null;
|
|
||||||
viewing: PulseWithCounts | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pulses, editing, viewing } = Astro.props;
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: 'Draft',
|
|
||||||
open: 'Open',
|
|
||||||
closed: 'Closed',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Convert SQL UTC date "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM" for datetime-local input. */
|
|
||||||
function toInputValue(sql: string | null | undefined): string {
|
|
||||||
if (!sql) return '';
|
|
||||||
return sql.replace(' ', 'T').slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formAction = editing ? 'update_pulse' : 'create_pulse';
|
|
||||||
const optionsForForm: string[] = editing ? [...editing.options] : [];
|
|
||||||
while (optionsForForm.length < 4) optionsForForm.push('');
|
|
||||||
---
|
|
||||||
<div class="tab-content">
|
|
||||||
|
|
||||||
{viewing ? (
|
|
||||||
<!-- ── Results view ─────────────────────────────────────────── -->
|
|
||||||
<section class="section">
|
|
||||||
<a href="/admin?tab=pulses" class="back-link label-sm">← Back to pulses</a>
|
|
||||||
<h2 class="label-sm section-heading">Results — {STATUS_LABEL[viewing.status]}</h2>
|
|
||||||
<p class="pulse-question-display">{viewing.question}</p>
|
|
||||||
{viewing.context && <p class="body-md muted">{viewing.context}</p>}
|
|
||||||
<p class="body-sm muted">Open {fmtDateTime(viewing.opens_at)} → {fmtDateTime(viewing.closes_at)} · {viewing.votes_total} vote{viewing.votes_total === 1 ? '' : 's'}</p>
|
|
||||||
<div class="results-grid">
|
|
||||||
{viewing.options.map((opt, i) => {
|
|
||||||
const count = viewing.votes_by_option[i] ?? 0;
|
|
||||||
const pct = viewing.votes_total > 0 ? (count / viewing.votes_total) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div class="result-row">
|
|
||||||
<div class="result-meta">
|
|
||||||
<span class="result-letter label-sm">{String.fromCharCode(65 + i)}</span>
|
|
||||||
<span class="result-text">{opt}</span>
|
|
||||||
<span class="result-count label-sm">{count} ({pct.toFixed(0)}%)</span>
|
|
||||||
</div>
|
|
||||||
<div class="result-bar"><span class="result-bar-fill" style={`width:${pct.toFixed(1)}%`}></span></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<!-- ── Create / edit form ──────────────────────────────────── -->
|
|
||||||
<section class="section">
|
|
||||||
<h2 class="label-sm section-heading">{editing ? 'Edit pulse' : 'New pulse'}</h2>
|
|
||||||
<form method="POST" class="invite-form" novalidate>
|
|
||||||
<input type="hidden" name="action" value={formAction} />
|
|
||||||
{editing && <input type="hidden" name="pulse_id" value={editing.id} />}
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="question" class="label-sm field-label">Question</label>
|
|
||||||
<input type="text" id="question" name="question" class="input body-md" required value={editing?.question ?? ''} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="context" class="label-sm field-label">Context (optional)</label>
|
|
||||||
<textarea id="context" name="context" class="input body-md" rows="3">{editing?.context ?? ''}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset class="option-grid">
|
|
||||||
<legend class="label-sm field-label">Options (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>
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
---
|
|
||||||
import type { RoadmapItemWithAttribution, UserPublic } from '../../lib/db';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: RoadmapItemWithAttribution[];
|
|
||||||
editing: RoadmapItemWithAttribution | null;
|
|
||||||
cabUsers: UserPublic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, editing, cabUsers } = Astro.props;
|
|
||||||
|
|
||||||
const STATUS_LABEL = { shipping: 'Shipping', 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>
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
---
|
|
||||||
import type { UserPublic } from '../../lib/db';
|
|
||||||
import { readFocusTags } from '../../lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
member: UserPublic;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { member } = Astro.props;
|
|
||||||
const tagsStr = readFocusTags(member.focus_tags).join(', ');
|
|
||||||
---
|
|
||||||
<div class="tab-content">
|
|
||||||
<section class="section">
|
|
||||||
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
|
|
||||||
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
|
|
||||||
|
|
||||||
<form method="POST" class="invite-form" novalidate>
|
|
||||||
<input type="hidden" name="action" value="update_user_admin" />
|
|
||||||
<input type="hidden" name="user_id" value={member.id} />
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label-sm field-label">Name</label>
|
|
||||||
<input type="text" class="input body-md" value={member.name} disabled />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label-sm field-label">Email</label>
|
|
||||||
<input type="text" class="input body-md" value={member.email} disabled />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label-sm field-label">Organisation</label>
|
|
||||||
<input type="text" class="input body-md" value={member.organisation} disabled />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
|
|
||||||
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="title" class="label-sm field-label">Job title</label>
|
|
||||||
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
|
|
||||||
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
|
|
||||||
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
|
|
||||||
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary label-sm">Save changes</button>
|
|
||||||
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="body-sm note">
|
|
||||||
Role transitions and deactivation live in the participants table.
|
|
||||||
A member-number is allocated the first time a user becomes CAB and is never reused.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Tiny live counter for the 200-char pull-quote field — no framework.
|
|
||||||
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
|
|
||||||
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
|
|
||||||
if (!counter) return;
|
|
||||||
const update = () => { counter.textContent = `${el.value.length} / 200`; };
|
|
||||||
el.addEventListener('input', update);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
|
||||||
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
|
|
||||||
.note {
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
max-width: var(--reading-max);
|
|
||||||
}
|
|
||||||
.input:disabled {
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
background: var(--surface-container-low);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -15,8 +15,7 @@ const navLinks = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const footerLinks = [
|
const footerLinks = [
|
||||||
{ href: '/vision', label: 'Vision' },
|
{ href: '/vision', label: 'Vision' },
|
||||||
{ href: '/council-manifesto', label: 'Council manifesto' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
@ -32,7 +31,21 @@ const year = new Date().getFullYear();
|
||||||
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
|
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav-right" aria-label="Main navigation">
|
<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">
|
||||||
{navLinks.map(({ href, label }) => (
|
{navLinks.map(({ href, label }) => (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
|
@ -112,35 +125,52 @@ const year = new Date().getFullYear();
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: 12px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
|
line-height: 1; /* belt + braces — no nav-row leading on the lockup */
|
||||||
}
|
}
|
||||||
.wordmark-link:hover {
|
.wordmark-link:hover {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
.wordmark {
|
.wordmark {
|
||||||
height: 22px;
|
height: 30px; /* 50% larger than the prior 20px lockup */
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.wordmark-sep {
|
.wordmark-sep {
|
||||||
|
/* Flex-centred against the logo height so the dot sits on the vertical
|
||||||
|
middle of the "Fenja AI" logo. */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
font-size: 1rem;
|
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);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.wordmark-project {
|
.wordmark-project {
|
||||||
font-family: var(--font-sans);
|
font-size: 20px;
|
||||||
font-size: var(--text-body-md);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
.wordmark-bifrost {
|
.wordmark-bifrost {
|
||||||
font-family: var(--font-serif);
|
display: inline-block;
|
||||||
|
font-size: 19px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 400;
|
padding: 2px 0;
|
||||||
|
vertical-align: baseline;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--pigment-terracotta) 0%,
|
var(--pigment-terracotta) 0%,
|
||||||
|
|
@ -154,6 +184,23 @@ const year = new Date().getFullYear();
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile menu toggle (hidden on desktop) ─────────────────────── */
|
||||||
|
.nav-toggle {
|
||||||
|
display: none;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--on-surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.nav-toggle .nav-toggle-x { display: none; }
|
||||||
|
|
||||||
/* ── Nav links ──────────────────────────────────────────────────── */
|
/* ── Nav links ──────────────────────────────────────────────────── */
|
||||||
.nav-right {
|
.nav-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -165,14 +212,15 @@ const year = new Date().getFullYear();
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--ghost-border-color);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
margin: 0 var(--space-2);
|
margin: 0 18px;
|
||||||
transform: scaleX(0.5);
|
transform: scaleX(0.5);
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
.nav-logout-form { display: inline-flex; }
|
.nav-logout-form { display: inline-flex; }
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-md);
|
font-size: var(--text-label-md);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -183,17 +231,17 @@ const year = new Date().getFullYear();
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
transition: color var(--duration-fast) var(--ease-standard),
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
background var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
background: var(--surface-container-low);
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
/* Active nav link: terracotta + slightly heavier weight. Colour alone
|
||||||
|
is the indicator — no badge, bullet, italic, or family swap. */
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
color: var(--on-surface);
|
color: var(--pigment-terracotta);
|
||||||
background: var(--surface-container);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── User zone ──────────────────────────────────────────────────── */
|
/* ── User zone ──────────────────────────────────────────────────── */
|
||||||
|
|
@ -286,4 +334,80 @@ const year = new Date().getFullYear();
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.nav-inner { padding: 0 var(--space-5); gap: var(--space-3); }
|
||||||
|
|
||||||
|
.nav-toggle { display: inline-flex; }
|
||||||
|
.nav.open .nav-toggle-bars { display: none; }
|
||||||
|
.nav.open .nav-toggle-x { display: inline; }
|
||||||
|
|
||||||
|
/* Right-hand nav becomes a full-width dropdown under the bar. */
|
||||||
|
.nav-right {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: var(--space-2) var(--space-5) var(--space-4);
|
||||||
|
background: var(--glass-surface);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
border-bottom: var(--ghost-border);
|
||||||
|
box-shadow: var(--shadow-float);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav.open .nav-right { display: flex; }
|
||||||
|
|
||||||
|
/* Comfortable tap targets in the dropdown. */
|
||||||
|
.nav-right .nav-link,
|
||||||
|
.nav-right .nav-user-name,
|
||||||
|
.nav-right .logout-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-2);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.nav-divider {
|
||||||
|
width: auto;
|
||||||
|
height: 1px;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
transform: scaleY(0.5);
|
||||||
|
}
|
||||||
|
.nav-logout-form { width: 100%; }
|
||||||
|
.logout-btn { justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* Footer stacks. */
|
||||||
|
.footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0 var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mobile nav: toggle the dropdown, keep aria in sync, close on link tap or
|
||||||
|
// when the viewport grows back to desktop.
|
||||||
|
const nav = document.querySelector<HTMLElement>('.nav');
|
||||||
|
const toggle = nav?.querySelector<HTMLButtonElement>('#nav-toggle');
|
||||||
|
const menu = nav?.querySelector<HTMLElement>('#nav-menu');
|
||||||
|
|
||||||
|
function setOpen(open: boolean) {
|
||||||
|
if (!nav || !toggle) return;
|
||||||
|
nav.classList.toggle('open', open);
|
||||||
|
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle?.addEventListener('click', () => setOpen(!nav!.classList.contains('open')));
|
||||||
|
menu?.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => setOpen(false)));
|
||||||
|
window.addEventListener('resize', () => { if (window.innerWidth > 767) setOpen(false); });
|
||||||
|
</script>
|
||||||
|
|
|
||||||
263
src/lib/db.ts
263
src/lib/db.ts
|
|
@ -152,6 +152,15 @@ export function updateUserProfile(id: number, name: string, bio: string): void {
|
||||||
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
|
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a user's email — their login identity. Throws if the address is
|
||||||
|
* already used by another account (the column is UNIQUE). */
|
||||||
|
export function updateUserEmail(id: number, email: string): void {
|
||||||
|
const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?')
|
||||||
|
.get(email, id) as { id: number } | undefined;
|
||||||
|
if (clash) throw new Error('That email is already in use by another account.');
|
||||||
|
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the newly-allocated member_number when the transition lands on
|
/** Returns the newly-allocated member_number when the transition lands on
|
||||||
* cab and the user had none; null otherwise. Callers may ignore. */
|
* cab and the user had none; null otherwise. Callers may ignore. */
|
||||||
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
|
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
|
||||||
|
|
@ -213,7 +222,8 @@ export function updateUserAdminFields(id: number, data: {
|
||||||
export function slugifyName(name: string): string {
|
export function slugifyName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics
|
.replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact
|
||||||
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^-+|-+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
@ -304,6 +314,15 @@ export function getAllInvites(): (Invite & { creator_name: string | null })[] {
|
||||||
`).all() as (Invite & { creator_name: string | null })[];
|
`).all() as (Invite & { creator_name: string | null })[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInviteById(id: number): (Invite & { creator_name: string | null }) | null {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT i.*, u.name AS creator_name
|
||||||
|
FROM invites i
|
||||||
|
LEFT JOIN users u ON u.id = i.created_by_user_id
|
||||||
|
WHERE i.id = ?
|
||||||
|
`).get(id) as (Invite & { creator_name: string | null }) | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contributions ────────────────────────────────────────────────
|
// ── Contributions ────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createContribution(data: {
|
export function createContribution(data: {
|
||||||
|
|
@ -457,6 +476,20 @@ export function getAllJoinRequests(): JoinRequest[] {
|
||||||
`).all() as JoinRequest[];
|
`).all() as JoinRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getJoinRequestById(id: number): JoinRequest | null {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT jr.id, jr.user_id, jr.created_at,
|
||||||
|
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
|
||||||
|
FROM join_requests jr
|
||||||
|
JOIN users u ON u.id = jr.user_id
|
||||||
|
WHERE jr.id = ?
|
||||||
|
`).get(id) as JoinRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteJoinRequest(id: number): void {
|
||||||
|
db.prepare('DELETE FROM join_requests WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Date helpers ─────────────────────────────────────────────────
|
// ── Date helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */
|
/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */
|
||||||
|
|
@ -634,6 +667,21 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
|
||||||
).run(pulseId, userId, optionIndex);
|
).run(pulseId, userId, optionIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UPSERT — first vote inserts, subsequent ones update option_index + voted_at.
|
||||||
|
* Use this when the UI allows members to change their pick while the pulse is
|
||||||
|
* still open. Returns true if this was a brand-new vote (so callers can
|
||||||
|
* record activity once), false if it changed an existing vote. */
|
||||||
|
export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean {
|
||||||
|
const existing = getUserVote(pulseId, userId);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO votes (pulse_id, user_id, option_index, voted_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(pulse_id, user_id) DO UPDATE
|
||||||
|
SET option_index = excluded.option_index, voted_at = excluded.voted_at
|
||||||
|
`).run(pulseId, userId, optionIndex);
|
||||||
|
return existing === null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserVote(pulseId: number, userId: number): number | null {
|
export function getUserVote(pulseId: number, userId: number): number | null {
|
||||||
const r = db.prepare(
|
const r = db.prepare(
|
||||||
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
|
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'
|
||||||
|
|
@ -650,7 +698,7 @@ export function countPulseParticipants(pulseId: number): number {
|
||||||
|
|
||||||
// ── Roadmap items ────────────────────────────────────────────────
|
// ── Roadmap items ────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring';
|
export type RoadmapStatus = 'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering';
|
||||||
|
|
||||||
export interface RoadmapItem {
|
export interface RoadmapItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -660,6 +708,7 @@ export interface RoadmapItem {
|
||||||
target: string | null;
|
target: string | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
shipped_at: string | null;
|
shipped_at: string | null;
|
||||||
|
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -674,20 +723,32 @@ export function createRoadmapItem(data: {
|
||||||
status: RoadmapStatus;
|
status: RoadmapStatus;
|
||||||
target?: string | null;
|
target?: string | null;
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
|
metadata_text?: string | null;
|
||||||
}): number {
|
}): number {
|
||||||
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
|
||||||
const r = db.prepare(`
|
const requestedOrder = data.display_order ?? 0;
|
||||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
|
||||||
VALUES (?,?,?,?,?,?)
|
return db.transaction(() => {
|
||||||
`).run(
|
// Cascade: insert at position N shifts every existing item at or after N
|
||||||
data.title,
|
// down by one, keeping the order dense.
|
||||||
data.description,
|
db.prepare(
|
||||||
data.status,
|
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?'
|
||||||
data.target ?? null,
|
).run(requestedOrder);
|
||||||
data.display_order ?? 0,
|
|
||||||
shipped_at,
|
const r = db.prepare(`
|
||||||
);
|
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
|
||||||
return Number(r.lastInsertRowid);
|
VALUES (?,?,?,?,?,?,?)
|
||||||
|
`).run(
|
||||||
|
data.title,
|
||||||
|
data.description,
|
||||||
|
data.status,
|
||||||
|
data.target ?? null,
|
||||||
|
requestedOrder,
|
||||||
|
shipped_at,
|
||||||
|
data.metadata_text ?? null,
|
||||||
|
);
|
||||||
|
return Number(r.lastInsertRowid);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -700,9 +761,10 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
status: RoadmapStatus;
|
status: RoadmapStatus;
|
||||||
target: string | null;
|
target: string | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
|
metadata_text?: string | null;
|
||||||
}): { shippedNow: boolean } {
|
}): { shippedNow: boolean } {
|
||||||
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
|
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
|
||||||
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
|
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
|
||||||
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
if (!current) throw new Error(`Roadmap item ${id} not found`);
|
||||||
|
|
||||||
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
|
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
|
||||||
|
|
@ -710,18 +772,45 @@ export function updateRoadmapItem(id: number, data: {
|
||||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
: current.shipped_at;
|
: current.shipped_at;
|
||||||
|
|
||||||
db.prepare(`
|
return db.transaction(() => {
|
||||||
UPDATE roadmap_items
|
// Cascade neighbours when display_order changes.
|
||||||
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
// Moving forward (A → B, B > A): rows in (A, B] shift down by 1.
|
||||||
shipped_at = ?, updated_at = datetime('now')
|
// Moving back (A → B, B < A): rows in [B, A) shift up by 1.
|
||||||
WHERE id = ?
|
const from = current.display_order;
|
||||||
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
return { shippedNow };
|
db.prepare(`
|
||||||
|
UPDATE roadmap_items
|
||||||
|
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
|
||||||
|
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
|
||||||
|
|
||||||
|
return { shippedNow };
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRoadmapItem(id: number): void {
|
export function deleteRoadmapItem(id: number): void {
|
||||||
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
|
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 {
|
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {
|
||||||
|
|
@ -1004,6 +1093,7 @@ export interface Dispatch {
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
pulse_id: number | null; // attached poll, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchWithAuthor extends Dispatch {
|
export interface DispatchWithAuthor extends Dispatch {
|
||||||
|
|
@ -1012,6 +1102,18 @@ export interface DispatchWithAuthor extends Dispatch {
|
||||||
author_role: Role;
|
author_role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DispatchWithPoll extends DispatchWithAuthor {
|
||||||
|
poll: PulseWithCounts | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional poll attachment used when creating/updating a dispatch. */
|
||||||
|
export interface DispatchPollInput {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
opens_at: string;
|
||||||
|
closes_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDispatch(data: {
|
export function createDispatch(data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -1019,46 +1121,119 @@ export function createDispatch(data: {
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
status: DispatchStatus;
|
status: DispatchStatus;
|
||||||
|
poll?: DispatchPollInput | null;
|
||||||
}): number {
|
}): number {
|
||||||
const published_at = data.status === 'published'
|
const published_at = data.status === 'published'
|
||||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
: null;
|
: null;
|
||||||
const r = db.prepare(`
|
return db.transaction(() => {
|
||||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
|
let pulseId: number | null = null;
|
||||||
VALUES (?,?,?,?,?,?,?)
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
|
pulseId = createPulse({
|
||||||
return Number(r.lastInsertRowid);
|
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);
|
||||||
|
return Number(r.lastInsertRowid);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a dispatch and, optionally, manage its attached poll. */
|
||||||
export function updateDispatch(id: number, data: {
|
export function updateDispatch(id: number, data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
|
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
|
||||||
|
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
|
||||||
}): void {
|
}): void {
|
||||||
db.prepare(`
|
db.transaction(() => {
|
||||||
UPDATE dispatches
|
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
|
||||||
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
|
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
|
||||||
WHERE id = ?
|
if (!cur) return;
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
|
|
||||||
|
let pulseId: number | null = cur.pulse_id;
|
||||||
|
|
||||||
|
if (data.pollExplicit) {
|
||||||
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
|
if (cur.pulse_id) {
|
||||||
|
// update the existing pulse in place
|
||||||
|
updatePulse(cur.pulse_id, {
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pulseId = createPulse({
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
status: cur.status === 'published' ? 'open' : 'draft',
|
||||||
|
created_by: data.author_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// explicit detach
|
||||||
|
pulseId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE dispatches
|
||||||
|
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
|
||||||
|
pulse_id = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch + its attached poll (with counts + this viewer's vote). */
|
||||||
|
export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null {
|
||||||
|
const d = getDispatchById(dispatchId);
|
||||||
|
if (!d) return null;
|
||||||
|
const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null;
|
||||||
|
return { ...d, poll };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Promote draft → published, stamping published_at = now() on first publish.
|
/** Promote draft → published, stamping published_at = now() on first publish.
|
||||||
* Idempotent: if already published, published_at is preserved. */
|
* Idempotent: if already published, published_at is preserved. Also opens
|
||||||
|
* any attached draft poll so members can start voting. */
|
||||||
export function publishDispatch(id: number): void {
|
export function publishDispatch(id: number): void {
|
||||||
db.prepare(`
|
db.transaction(() => {
|
||||||
UPDATE dispatches
|
db.prepare(`
|
||||||
SET status = 'published',
|
UPDATE dispatches
|
||||||
published_at = COALESCE(published_at, datetime('now')),
|
SET status = 'published',
|
||||||
updated_at = datetime('now')
|
published_at = COALESCE(published_at, datetime('now')),
|
||||||
WHERE id = ?
|
updated_at = datetime('now')
|
||||||
`).run(id);
|
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. */
|
/** Archive a dispatch. Leaves published_at intact for history. Closes any
|
||||||
|
* attached open poll so the bar charts read final. */
|
||||||
export function archiveDispatch(id: number): void {
|
export function archiveDispatch(id: number): void {
|
||||||
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
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 {
|
export function deleteDispatch(id: number): void {
|
||||||
|
|
|
||||||
Binary file not shown.
137
src/lib/roadmap-layout.ts
Normal file
137
src/lib/roadmap-layout.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
35
src/lib/uploads.ts
Normal file
35
src/lib/uploads.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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,4 +293,8 @@ const saved = Astro.url.searchParams.get('saved') === '1';
|
||||||
color: var(--on-surface-muted);
|
color: var(--on-surface-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
256
src/pages/admin/[resource].astro
Normal file
256
src/pages/admin/[resource].astro
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
---
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* /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,882 +1,17 @@
|
||||||
---
|
---
|
||||||
import AppLayout from '../../layouts/AppLayout.astro';
|
/* ---------------------------------------------------------------------------
|
||||||
import {
|
* /admin — redirect to the first registered resource.
|
||||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
*
|
||||||
createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
|
* Auth-gated like every other admin page. Members hitting /admin without
|
||||||
getUserPublicById, getAllJoinRequests,
|
* the fenja role land on /; admins land on the dispatches list view (the
|
||||||
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
|
* default Backstage home).
|
||||||
getAllPulses, getPulseById, getPulseWithCounts,
|
* ------------------------------------------------------------------------- */
|
||||||
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
|
|
||||||
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
|
import { groups } from '../../admin/resources';
|
||||||
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
|
|
||||||
getEventRsvpCount, getEventById,
|
|
||||||
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
|
|
||||||
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
|
|
||||||
recordActivity, getAllActivityForAdmin,
|
|
||||||
} from '../../lib/db';
|
|
||||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
|
||||||
import { fmtDate } from '../../lib/markdown';
|
|
||||||
import { parseFocusTags } from '../../lib/format';
|
|
||||||
import { notifyPulseOpened } from '../../lib/notify';
|
|
||||||
import PulsesTab from '../../components/admin/PulsesTab.astro';
|
|
||||||
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
|
|
||||||
import EventsTab from '../../components/admin/EventsTab.astro';
|
|
||||||
import ActivityTab from '../../components/admin/ActivityTab.astro';
|
|
||||||
import DispatchesTab from '../../components/admin/DispatchesTab.astro';
|
|
||||||
import UserEditTab from '../../components/admin/UserEditTab.astro';
|
|
||||||
import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db';
|
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
|
if (user.role !== 'fenja') return Astro.redirect('/');
|
||||||
|
|
||||||
// Guard: fenja only
|
const first = groups.flatMap((g) => g.resources)[0];
|
||||||
if (user.role !== 'fenja') {
|
return Astro.redirect(first ? `/admin/${first.key}` : '/');
|
||||||
return Astro.redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = Astro.url.searchParams.get('tab') ?? 'invitations';
|
|
||||||
|
|
||||||
let newInviteToken: string | null = null;
|
|
||||||
let formError: string | null = null;
|
|
||||||
let actionMsg: string | null = null;
|
|
||||||
|
|
||||||
if (Astro.request.method === 'POST') {
|
|
||||||
const data = await Astro.request.formData();
|
|
||||||
const action = String(data.get('action') ?? '');
|
|
||||||
|
|
||||||
if (action === 'create_invite') {
|
|
||||||
const name = String(data.get('name') ?? '').trim();
|
|
||||||
const email = String(data.get('email') ?? '').trim().toLowerCase();
|
|
||||||
const organisation = String(data.get('organisation') ?? '').trim();
|
|
||||||
const role = String(data.get('role') ?? '') as Role;
|
|
||||||
|
|
||||||
if (!name || !email || !organisation || !['pilot','cab','fenja'].includes(role)) {
|
|
||||||
formError = 'All fields are required.';
|
|
||||||
} else {
|
|
||||||
const { token, tokenHash } = generateInviteToken();
|
|
||||||
createInvite({
|
|
||||||
token_hash: tokenHash,
|
|
||||||
email,
|
|
||||||
name,
|
|
||||||
organisation,
|
|
||||||
role,
|
|
||||||
expires_at: inviteExpiresAt(),
|
|
||||||
created_by_user_id: user.id,
|
|
||||||
});
|
|
||||||
newInviteToken = `${Astro.url.origin}/invite/${token}`;
|
|
||||||
}
|
|
||||||
} else if (action === 'revoke_invite') {
|
|
||||||
const id = Number(data.get('invite_id'));
|
|
||||||
if (id) revokeInvite(id);
|
|
||||||
return Astro.redirect('/admin?tab=invitations&msg=revoked');
|
|
||||||
} else if (action === 'change_role') {
|
|
||||||
const userId = Number(data.get('user_id'));
|
|
||||||
const newRole = String(data.get('role')) as Role;
|
|
||||||
if (userId && ['pilot','cab','fenja'].includes(newRole)) {
|
|
||||||
updateUserRole(userId, newRole);
|
|
||||||
}
|
|
||||||
return Astro.redirect('/admin?tab=participants&msg=updated');
|
|
||||||
} else if (action === 'deactivate_user') {
|
|
||||||
const userId = Number(data.get('user_id'));
|
|
||||||
if (userId && userId !== user.id) deactivateUser(userId);
|
|
||||||
return Astro.redirect('/admin?tab=participants&msg=deactivated');
|
|
||||||
|
|
||||||
// ── User profile edit (title / pull_quote / focus_tags) ─────
|
|
||||||
} else if (action === 'update_user_admin') {
|
|
||||||
const userId = Number(data.get('user_id'));
|
|
||||||
if (userId) {
|
|
||||||
const title = String(data.get('title') ?? '').trim() || null;
|
|
||||||
const pullQuote = String(data.get('pull_quote') ?? '').trim() || null;
|
|
||||||
const tagsInput = String(data.get('focus_tags') ?? '');
|
|
||||||
const focusTags = parseFocusTags(tagsInput);
|
|
||||||
updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags });
|
|
||||||
}
|
|
||||||
return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`);
|
|
||||||
|
|
||||||
// ── Dispatches ───────────────────────────────────────────────
|
|
||||||
} else if (action === 'create_dispatch' || action === 'update_dispatch') {
|
|
||||||
const title = String(data.get('title') ?? '').trim();
|
|
||||||
const body = String(data.get('body') ?? '');
|
|
||||||
const excerpt = String(data.get('excerpt') ?? '').trim() || null;
|
|
||||||
const kind = String(data.get('kind') ?? '') as DispatchKind;
|
|
||||||
const authorId = Number(data.get('author_id'));
|
|
||||||
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
|
||||||
55
src/pages/api/admin/upload.ts
Normal file
55
src/pages/api/admin/upload.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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,4 +524,10 @@ const typeColors: Record<ContributionType, string> = {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
/* Filter + sort tab groups wrap instead of overflowing. */
|
||||||
|
.feed-controls { flex-wrap: wrap; gap: var(--space-3); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -191,4 +191,8 @@ if (Astro.request.method === 'POST') {
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
.btn-primary:hover { opacity: 0.9; }
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,8 @@ const user = Astro.locals.user;
|
||||||
.page-title { margin: 0; }
|
.page-title { margin: 0; }
|
||||||
.reading-col { max-width: var(--reading-max); }
|
.reading-col { max-width: var(--reading-max); }
|
||||||
.lead { color: var(--on-surface-variant); }
|
.lead { color: var(--on-surface-variant); }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
---
|
---
|
||||||
import AppLayout from '../../layouts/AppLayout.astro';
|
import AppLayout from '../../layouts/AppLayout.astro';
|
||||||
import Avatar from '../../components/Avatar.astro';
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
import {
|
||||||
|
getDispatchWithPoll, getAdjacentDispatches,
|
||||||
|
getPulseById, castOrChangeVote, recordActivity, countCabMembers,
|
||||||
|
} from '../../lib/db';
|
||||||
import {
|
import {
|
||||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||||
dispatchKindPigment, roleLabel,
|
dispatchKindPigment, roleLabel,
|
||||||
|
|
@ -14,15 +17,38 @@ const id = parseDispatchSlug(slugParam);
|
||||||
|
|
||||||
if (!id) return Astro.redirect('/dispatches');
|
if (!id) return Astro.redirect('/dispatches');
|
||||||
|
|
||||||
const d = getDispatchById(id);
|
// 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);
|
||||||
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
||||||
|
|
||||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||||
const canonical = dispatchSlug(d);
|
const canonical = dispatchSlug(d);
|
||||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||||
|
|
||||||
|
const totalMembers = countCabMembers();
|
||||||
const { prev, next } = getAdjacentDispatches(d.id);
|
const { prev, next } = getAdjacentDispatches(d.id);
|
||||||
|
|
||||||
|
function closeDayLabel(closesAt: string): string {
|
||||||
|
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function parseUtc(s: string): Date {
|
function parseUtc(s: string): Date {
|
||||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||||
return new Date(s.replace(' ', 'T') + 'Z');
|
return new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
|
@ -63,6 +89,51 @@ const bodyHtml = renderMd(d.body);
|
||||||
|
|
||||||
<div class="body prose" set:html={bodyHtml} />
|
<div class="body prose" set:html={bodyHtml} />
|
||||||
|
|
||||||
|
{d.poll && (
|
||||||
|
<aside class="inline-poll" aria-label="Poll attached to this dispatch">
|
||||||
|
<p class="inline-poll-question">{d.poll.question}</p>
|
||||||
|
<form method="POST" class="inline-poll-options" novalidate>
|
||||||
|
<input type="hidden" name="action" value="vote" />
|
||||||
|
<input type="hidden" name="pulse_id" value={d.poll.id} />
|
||||||
|
{d.poll.options.map((opt, i) => {
|
||||||
|
const hasVoted = d.poll!.my_vote !== null;
|
||||||
|
const chosen = d.poll!.my_vote === i;
|
||||||
|
const closed = d.poll!.status !== 'open';
|
||||||
|
const count = d.poll!.votes_by_option[i] ?? 0;
|
||||||
|
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0;
|
||||||
|
const letter = String.fromCharCode(65 + i);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="option_index"
|
||||||
|
value={i}
|
||||||
|
class:list={['inline-poll-option', { chosen, closed }]}
|
||||||
|
disabled={closed && !chosen}
|
||||||
|
aria-pressed={chosen}
|
||||||
|
>
|
||||||
|
<span class="inline-poll-letter">{letter}</span>
|
||||||
|
<span class="inline-poll-text">{opt}</span>
|
||||||
|
{hasVoted && (
|
||||||
|
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
|
||||||
|
)}
|
||||||
|
{hasVoted && (
|
||||||
|
<span class="inline-poll-bar" aria-hidden="true">
|
||||||
|
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
<p class="inline-poll-count">
|
||||||
|
{d.poll.votes_total} of {totalMembers} have weighed in
|
||||||
|
{d.poll.status === 'open'
|
||||||
|
? ` · closes ${closeDayLabel(d.poll.closes_at)}`
|
||||||
|
: ' · closed'}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<hr class="divider" />
|
<hr class="divider" />
|
||||||
|
|
||||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||||
|
|
@ -96,7 +167,7 @@ const bodyHtml = renderMd(d.body);
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||||
max-width: 720px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -194,6 +265,95 @@ const bodyHtml = renderMd(d.body);
|
||||||
margin: var(--space-6) 0 0;
|
margin: var(--space-6) 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inline poll attached to the dispatch ──────────────────────── */
|
||||||
|
.inline-poll {
|
||||||
|
margin-top: var(--space-7);
|
||||||
|
background: rgba(185, 107, 88, 0.08);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.inline-poll-question {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.inline-poll-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.inline-poll-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--background);
|
||||||
|
border: 0.5px solid var(--surface-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
color: var(--on-surface);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||||
|
background var(--duration-fast) var(--ease-standard);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
|
||||||
|
.inline-poll-option.chosen {
|
||||||
|
border-color: var(--pigment-terracotta);
|
||||||
|
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
|
||||||
|
}
|
||||||
|
.inline-poll-option.closed:not(.chosen) {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.inline-poll-pct {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
|
||||||
|
.inline-poll-option:disabled { opacity: 0.85; }
|
||||||
|
.inline-poll-letter {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.inline-poll-option.chosen .inline-poll-letter { color: var(--pigment-terracotta); }
|
||||||
|
.inline-poll-text { flex: 1; }
|
||||||
|
.inline-poll-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--surface-container);
|
||||||
|
}
|
||||||
|
.inline-poll-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--pigment-terracotta);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: width 600ms var(--ease-standard);
|
||||||
|
}
|
||||||
|
.inline-poll-count {
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.adjacent {
|
.adjacent {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
@ -239,6 +399,7 @@ const bodyHtml = renderMd(d.body);
|
||||||
.adj-empty {} /* placeholder for missing prev/next slot */
|
.adj-empty {} /* placeholder for missing prev/next slot */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
.adjacent { grid-template-columns: 1fr; }
|
.adjacent { grid-template-columns: 1fr; }
|
||||||
.adj-next { text-align: left; align-items: flex-start; }
|
.adj-next { text-align: left; align-items: flex-start; }
|
||||||
.adj-next .adj-kind-pill { align-self: flex-start; }
|
.adj-next .adj-kind-pill { align-self: flex-start; }
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ function fmt(iso: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
.d-link {
|
.d-link {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
/* ── Hero ─────────────────────────────────────────────────────── */
|
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||||
.hero {
|
.hero {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
}
|
}
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
left: 100px;
|
left: 100px;
|
||||||
top: 0; bottom: 0;
|
top: 0; bottom: 0;
|
||||||
width: 0.5px;
|
width: 0.5px;
|
||||||
background: rgba(232, 224, 208, 0.2);
|
background: var(--ink-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-date { display: flex; flex-direction: column; gap: 2px; }
|
.hero-date { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
.hero-day {
|
.hero-day {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 2.75rem;
|
font-size: 2.75rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-detail { padding-left: var(--space-5); }
|
.hero-detail { padding-left: var(--space-5); }
|
||||||
|
|
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-3);
|
||||||
}
|
}
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
color: rgba(232, 224, 208, 0.85);
|
color: var(--on-ink-body);
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-3);
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
.hero-meta {
|
.hero-meta {
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-foot {
|
.hero-foot {
|
||||||
border-top: 0.5px solid rgba(232, 224, 208, 0.2);
|
border-top: 0.5px solid var(--ink-divider);
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
}
|
}
|
||||||
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
||||||
.hero-foot-stat {
|
.hero-foot-stat {
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
|
|
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
||||||
|
|
||||||
.hero-cta {
|
.hero-cta {
|
||||||
background: var(--ink-text);
|
background: var(--on-ink);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
|
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
.hero-cta:hover { opacity: 0.85; }
|
.hero-cta:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.hero-confirmed {
|
.hero-confirmed {
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-md);
|
font-size: var(--text-label-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 0.5px solid rgba(232, 224, 208, 0.4);
|
border: 0.5px solid rgba(255, 252, 247, 0.4);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
.hero-change {
|
.hero-change {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--ink-muted);
|
color: var(--on-ink-muted);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: var(--text-label-sm);
|
font-size: var(--text-label-sm);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
|
|
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.hero-empty-line {
|
.hero-empty-line {
|
||||||
color: var(--ink-text);
|
color: var(--on-ink);
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
@ -504,6 +504,7 @@ const heroAudience = hero?.audience ?? 'Members only';
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
.hero-body { grid-template-columns: 1fr; }
|
.hero-body { grid-template-columns: 1fr; }
|
||||||
.hero-body::after { display: none; }
|
.hero-body::after { display: none; }
|
||||||
.past-grid { grid-template-columns: 1fr; }
|
.past-grid { grid-template-columns: 1fr; }
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
.past-list { grid-template-columns: 1fr; }
|
.past-list { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -563,4 +563,12 @@ const innofoundarLogoExists = existsSync(join(process.cwd(), 'public/innofounder
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||||
|
/* Stack the two-column rows. */
|
||||||
|
.fenja-row,
|
||||||
|
.ask-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
.m-row { grid-template-columns: 52px 1fr; }
|
.m-row { grid-template-columns: 52px 1fr; }
|
||||||
.m-tags {
|
.m-tags {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|
|
||||||
|
|
@ -421,4 +421,13 @@ const tiers = [
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||||
|
/* Stack the label / content / tag rows. */
|
||||||
|
.arch-row,
|
||||||
|
.tier-row,
|
||||||
|
.extends-item { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,212 +1,112 @@
|
||||||
---
|
---
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import AppLayout from '../layouts/AppLayout.astro';
|
import AppLayout from '../layouts/AppLayout.astro';
|
||||||
import { marked } from 'marked';
|
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
|
||||||
|
import RoadmapRoute from '../components/RoadmapRoute.astro';
|
||||||
|
import { getAllRoadmapItems } from '../lib/db';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
|
|
||||||
// Single-file roadmap — not a content collection
|
const items = getAllRoadmapItems()
|
||||||
const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
|
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
|
||||||
|
|
||||||
// Strip YAML frontmatter
|
|
||||||
const body = raw.replace(/^---[\s\S]*?---\n/, '');
|
|
||||||
|
|
||||||
// Parse sections by ## headings
|
|
||||||
function parseSections(md: string) {
|
|
||||||
const sectionRe = /^## (.+)$/gm;
|
|
||||||
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
|
|
||||||
const matches = [...md.matchAll(sectionRe)];
|
|
||||||
|
|
||||||
for (let i = 0; i < matches.length; i++) {
|
|
||||||
const m = matches[i];
|
|
||||||
const start = m.index! + m[0].length;
|
|
||||||
const end = matches[i + 1]?.index ?? md.length;
|
|
||||||
const sectionBody = md.slice(start, end).trim();
|
|
||||||
|
|
||||||
// Each item starts with **Title** — description
|
|
||||||
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
|
|
||||||
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
|
|
||||||
let itemMatch: RegExpExecArray | null;
|
|
||||||
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
|
|
||||||
const rawBody = itemMatch[2].trim();
|
|
||||||
const pilotOnly = rawBody.includes('`pilot-only`');
|
|
||||||
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
|
|
||||||
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({ title: m[1], items });
|
|
||||||
}
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = parseSections(body);
|
|
||||||
|
|
||||||
const horizonColors: Record<string, string> = {
|
|
||||||
'In progress': 'var(--pigment-copper)',
|
|
||||||
'Next': 'var(--pigment-ochre)',
|
|
||||||
'Later': 'var(--pigment-indigo)',
|
|
||||||
};
|
|
||||||
---
|
---
|
||||||
<AppLayout title="Roadmap" user={user}>
|
<AppLayout title="Roadmap" user={user}>
|
||||||
<div class="page">
|
<article class="roadmap-page">
|
||||||
|
|
||||||
<header class="page-header">
|
<!-- Single centred header — merges the page lead with the route's
|
||||||
<h1 class="display-md page-title">What we are building.</h1>
|
interaction hints. -->
|
||||||
<p class="subtitle">
|
<header class="roadmap-header">
|
||||||
Three horizons. What is in progress now, what comes next,
|
<h1 class="roadmap-title">Roadmap</h1>
|
||||||
and what is further out. This is the live picture.
|
<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.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="horizons">
|
<!-- Legend lives above the route now — reads as a key the eye picks
|
||||||
{sections.map((section) => (
|
up just before walking the path. -->
|
||||||
<section class="horizon">
|
<div class="roadmap-legend" aria-label="Status legend">
|
||||||
<div class="horizon-header">
|
<span><i style="background:#6d8c7c"></i>Shipping</span>
|
||||||
<span
|
<span><i style="background:#b96b58"></i>In beta</span>
|
||||||
class="horizon-dot"
|
<span><i style="background:#5a6d83"></i>Planned</span>
|
||||||
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
|
<span><i style="background:#b4b2a9"></i>Exploring</span>
|
||||||
aria-hidden="true"
|
<span><i style="background:#d4d2c8"></i>Considering</span>
|
||||||
/>
|
|
||||||
<h2 class="headline-sm horizon-title">{section.title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="item-list">
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<li class="item">
|
|
||||||
<div class="item-header">
|
|
||||||
<h3 class="item-title body-lg">{item.title}</h3>
|
|
||||||
{item.pilotOnly && (
|
|
||||||
<span class="pilot-badge label-sm" title="Available to pilot participants only">
|
|
||||||
Pilot
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p class="body-md item-body">{item.body}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
<RoadmapRoute items={items} />
|
||||||
|
|
||||||
|
<!-- Latest dispatch sits at the foot of the page with generous
|
||||||
|
space above so it reads as a separate beat, not a continuation
|
||||||
|
of the route. -->
|
||||||
|
<LatestDispatchBanner />
|
||||||
|
|
||||||
|
</article>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.roadmap-page {
|
||||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
padding: 0 36px 80px;
|
||||||
max-width: var(--content-max);
|
max-width: var(--content-max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────────────────────────────── */
|
/* ── Centred header ──────────────────────────────────────────── */
|
||||||
.page-header {
|
.roadmap-header {
|
||||||
max-width: 44rem;
|
text-align: center;
|
||||||
margin-bottom: var(--space-12);
|
max-width: 640px;
|
||||||
|
margin: 0 auto 56px; /* generous gap to the legend */
|
||||||
|
padding-top: 96px;
|
||||||
}
|
}
|
||||||
|
.roadmap-title {
|
||||||
.eyebrow {
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--on-surface-muted);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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-family: var(--font-serif);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: var(--tracking-snug);
|
font-size: 48px;
|
||||||
margin: 0;
|
line-height: 1.05;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
flex: 1;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
.roadmap-sub {
|
||||||
.pilot-badge {
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
line-height: 1.65;
|
||||||
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);
|
color: var(--on-surface-variant);
|
||||||
line-height: var(--leading-relaxed);
|
margin: 0 auto;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.roadmap-legend i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dispatch banner (foot of page, generous breathing room) ── */
|
||||||
|
.roadmap-page :global(.rr-dispatch) { margin-top: 112px; }
|
||||||
|
|
||||||
|
@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; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -240,4 +240,8 @@ const next = sorted[currentIndex - 1] ?? null;
|
||||||
.post-nav-link:hover .nav-title {
|
.post-nav-link:hover .nav-title {
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -168,4 +168,11 @@ const updates = allUpdates.sort(
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile (≤720px) ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
|
||||||
|
/* Date stacks above the content instead of a cramped 9rem column. */
|
||||||
|
.update-item { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
35
src/pages/uploads/[file].ts
Normal file
35
src/pages/uploads/[file].ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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,4 +289,10 @@ const user = Astro.locals.user;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page { padding: 0 var(--space-5) var(--space-12); }
|
||||||
|
.bifrost-context-section { grid-template-columns: 1fr; gap: var(--space-3); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -232,23 +232,27 @@ a:hover {
|
||||||
.ghost-border { border: var(--ghost-border); }
|
.ghost-border { border: var(--ghost-border); }
|
||||||
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
||||||
|
|
||||||
/* --- Section link — prominent italic serif, placed at the bottom of
|
/* --- Section link — black serif italic, underlined, larger.
|
||||||
its respective box or article. See points 8 + 10 in the v3 spec:
|
Placed at the bottom of its respective box or article.
|
||||||
italics are reserved for links + the Bifrost wordmark. --- */
|
Italics are reserved for links + the Bifrost wordmark. --- */
|
||||||
.section-link {
|
.section-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: var(--text-body-md);
|
font-size: var(--text-title-lg); /* 1.125rem — larger than body */
|
||||||
color: var(--pigment-terracotta);
|
color: var(--on-surface);
|
||||||
text-decoration: none;
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.5px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
.section-link:hover {
|
.section-link:hover {
|
||||||
|
color: var(--on-surface);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
color: var(--pigment-terracotta);
|
|
||||||
}
|
}
|
||||||
.section-link--ink {
|
.section-link--ink {
|
||||||
color: var(--ink-text);
|
color: var(--ink-text);
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,14 @@
|
||||||
--surface-card: #ffffff;
|
--surface-card: #ffffff;
|
||||||
--surface-card-border: rgba(0, 0, 0, 0.08);
|
--surface-card-border: rgba(0, 0, 0, 0.08);
|
||||||
--ink: #2c3a52; /* deep indigo — membership card + event hero */
|
--ink: #2c3a52; /* deep indigo — membership card + event hero */
|
||||||
--ink-text: #e8e0d0; /* readable cream on --ink */
|
--ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
|
||||||
--ink-muted: #b8a989; /* muted label tone 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 */
|
||||||
|
|
||||||
/* --- Semantic state mappings --- */
|
/* --- Semantic state mappings --- */
|
||||||
--color-success: var(--pigment-copper);
|
--color-success: var(--pigment-copper);
|
||||||
|
|
@ -65,14 +71,14 @@
|
||||||
--text-display-md: clamp(2.5rem, 4vw, 3.5rem);
|
--text-display-md: clamp(2.5rem, 4vw, 3.5rem);
|
||||||
--text-headline-lg: 2.25rem;
|
--text-headline-lg: 2.25rem;
|
||||||
--text-headline-md: 1.75rem;
|
--text-headline-md: 1.75rem;
|
||||||
--text-headline-sm: 1.375rem;
|
--text-headline-sm: 1.4375rem; /* 23px (was 22) */
|
||||||
--text-title-lg: 1.125rem;
|
--text-title-lg: 1.1875rem; /* 19px (was 18) */
|
||||||
--text-title-md: 1rem;
|
--text-title-md: 1.0625rem; /* 17px (was 16) */
|
||||||
--text-body-lg: 1.0625rem;
|
--text-body-lg: 1.125rem; /* 18px (was 17) */
|
||||||
--text-body-md: 1rem;
|
--text-body-md: 1.0625rem; /* 17px (was 16) — base body */
|
||||||
--text-body-sm: 0.875rem;
|
--text-body-sm: 0.9375rem; /* 15px (was 14) */
|
||||||
--text-label-md: 0.8125rem;
|
--text-label-md: 0.875rem; /* 14px (was 13) */
|
||||||
--text-label-sm: 0.75rem;
|
--text-label-sm: 0.8125rem; /* 13px (was 12) */
|
||||||
|
|
||||||
/* --- Tracking --- */
|
/* --- Tracking --- */
|
||||||
--tracking-tight: -0.02em;
|
--tracking-tight: -0.02em;
|
||||||
|
|
@ -129,6 +135,6 @@
|
||||||
--duration-slow: 420ms;
|
--duration-slow: 420ms;
|
||||||
|
|
||||||
/* --- Layout --- */
|
/* --- Layout --- */
|
||||||
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
|
--content-max: 72rem; /* 1152px */
|
||||||
--reading-max: 42rem; /* 672px */
|
--reading-max: 42rem; /* 672px */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
tests/admin-resources.test.ts
Normal file
169
tests/admin-resources.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
tests/admin-validate.test.ts
Normal file
113
tests/admin-validate.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { validateForResource } from '../src/admin/validate';
|
||||||
|
import type { Resource } from '../src/admin/resource-types';
|
||||||
|
|
||||||
|
// Minimal resource fixture covering the field kinds the validator handles.
|
||||||
|
const resource: Resource = {
|
||||||
|
key: 'fixtures',
|
||||||
|
label: 'Fixture',
|
||||||
|
pluralLabel: 'Fixtures',
|
||||||
|
singularLabel: 'Fixture',
|
||||||
|
groupKey: 'system',
|
||||||
|
list: { queryFn: () => [], columns: [] },
|
||||||
|
ops: {},
|
||||||
|
form: {
|
||||||
|
fields: [
|
||||||
|
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 5 },
|
||||||
|
{ key: 'kind', label: 'Kind', kind: 'select', options: [{ value: 'a', label: 'A' }] },
|
||||||
|
{
|
||||||
|
key: 'options',
|
||||||
|
label: 'Options',
|
||||||
|
kind: 'multi-text',
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 4,
|
||||||
|
},
|
||||||
|
{ key: 'count', label: 'Count', kind: 'number', min: 0, max: 10 },
|
||||||
|
{
|
||||||
|
key: 'extras',
|
||||||
|
label: 'Extras',
|
||||||
|
kind: 'text',
|
||||||
|
// Only visible when kind === 'a'
|
||||||
|
visibleWhen: (ctx) => ctx.formValues.kind === 'a',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseArgs = { resource, item: null, actingUserId: 1 };
|
||||||
|
|
||||||
|
describe('validateForResource', () => {
|
||||||
|
it('reports required fields that are empty', () => {
|
||||||
|
const errors = validateForResource({ ...baseArgs, data: {} });
|
||||||
|
expect(errors.title).toBe('Title is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports maxLength violations on text fields', () => {
|
||||||
|
const errors = validateForResource({ ...baseArgs, data: { title: 'too long' } });
|
||||||
|
expect(errors.title).toMatch(/5 characters or fewer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fields hidden by visibleWhen', () => {
|
||||||
|
// kind = 'b' hides the 'extras' required field — no error expected
|
||||||
|
const errors = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: { title: 'ok', kind: 'b' },
|
||||||
|
});
|
||||||
|
expect(errors.extras).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces visibleWhen-revealed required fields', () => {
|
||||||
|
const errors = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: { title: 'ok', kind: 'a' },
|
||||||
|
});
|
||||||
|
expect(errors.extras).toBe('Extras is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces multi-text min/max items', () => {
|
||||||
|
const tooFew = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: { title: 'ok', options: ['only-one'] },
|
||||||
|
});
|
||||||
|
expect(tooFew.options).toMatch(/at least 2/);
|
||||||
|
|
||||||
|
const tooMany = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: { title: 'ok', options: ['1', '2', '3', '4', '5'] },
|
||||||
|
});
|
||||||
|
expect(tooMany.options).toMatch(/at most 4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces number min/max', () => {
|
||||||
|
const tooHigh = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: { title: 'ok', count: 99 },
|
||||||
|
});
|
||||||
|
expect(tooHigh.count).toMatch(/no more than 10/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no errors when every field is valid', () => {
|
||||||
|
const errors = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
data: {
|
||||||
|
title: 'ok',
|
||||||
|
kind: 'a',
|
||||||
|
options: ['x', 'y'],
|
||||||
|
count: 5,
|
||||||
|
extras: 'fine',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(errors).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats form: null as always valid', () => {
|
||||||
|
const readOnly: Resource = { ...resource, form: null };
|
||||||
|
const errors = validateForResource({
|
||||||
|
...baseArgs,
|
||||||
|
resource: readOnly,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(errors).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
94
tests/roadmap-layout.test.ts
Normal file
94
tests/roadmap-layout.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
tests/tenure-milestone.test.ts
Normal file
33
tests/tenure-milestone.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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