Compare commits
10 commits
58faeffbc2
...
66c3f6492f
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c3f6492f | |||
| 637055a73e | |||
| ca3686de29 | |||
| 6b30593abb | |||
| 3240e0f052 | |||
| 1518bfa3d3 | |||
| ed2c272d3a | |||
| fd3f433933 | |||
| 1bf1993040 | |||
| b0e6d7e18b |
20 changed files with 2446 additions and 539 deletions
|
|
@ -12,7 +12,39 @@
|
|||
"Bash(node scripts/seed.js)",
|
||||
"Bash(pnpm typecheck *)",
|
||||
"Bash(pnpm build *)",
|
||||
"Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)"
|
||||
"Bash(identify /home/jonathan/Documents/DEV/Project-Bifrost/public/innofounder-logo.png)",
|
||||
"Bash(pnpm dev *)",
|
||||
"Bash(awk '{print $1}')",
|
||||
"Bash(pnpm db:migrate *)",
|
||||
"Bash(sqlite3 bifrost.db \".schema users\")",
|
||||
"Bash(sqlite3 bifrost.db \".schema attendance\")",
|
||||
"Bash(sqlite3 bifrost.db \"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;\")",
|
||||
"Bash(sqlite3 bifrost.db \"SELECT id, name, slug FROM users;\")",
|
||||
"Bash(node *)",
|
||||
"Bash(pnpm db:seed:roadmap)",
|
||||
"Bash(pnpm test *)",
|
||||
"Bash(curl -sI http://localhost:4321/pulse -b \"bifrost_session=test\")",
|
||||
"Bash(curl -sI http://localhost:4321/admin)",
|
||||
"Bash(pnpm db:seed:demo *)",
|
||||
"Bash(curl -s -c /tmp/jar.txt -b /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -i)",
|
||||
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/pulse)",
|
||||
"Bash(curl -s -c /tmp/jonjar.txt -b /tmp/jonjar.txt -d \"email=jonathan@fenja.ai&password=fenja123\" http://localhost:4321/login -i)",
|
||||
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=pulses\")",
|
||||
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=roadmap\")",
|
||||
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=events\")",
|
||||
"Bash(curl -s -b /tmp/jonjar.txt \"http://localhost:4321/admin?tab=activity\")",
|
||||
"Bash(awk NR>=107 && NR<=117 *)",
|
||||
"Bash(awk 'NR==111' src/lib/format.ts)",
|
||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@rigspolitiet.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
||||
"Bash(curl -sI -b /tmp/jar.txt http://localhost:4321/pulse)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/members)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/events)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches)",
|
||||
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/dispatches/1-we-are-deprioritising-public-cloud-parity-for-q3)",
|
||||
"Bash(pnpm db:seed)",
|
||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null -i)",
|
||||
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
// Demo seed for first-load credibility: one open pulse, one shipped roadmap
|
||||
// item attributed to the cab user, one dinner + one office hours event, and
|
||||
// a handful of hand-crafted activity rows so the ticker has something to
|
||||
// scroll on a fresh demo.
|
||||
// Phase 2 demo seed — produces the visual state described in SPEC §Phase 2:
|
||||
// 4 cab members with title/pull_quote/focus_tags/member_number, 1 active
|
||||
// pulse (2 of 4 voted), 4 roadmap items (1 shipping / 1 beta / 2 exploring),
|
||||
// 3 contributions with reactions, 4 dispatches at staggered ages, 1 hero
|
||||
// event + 1 studio hours + 1 working session + 2 past events.
|
||||
//
|
||||
// Idempotent: skips if a pulse already exists. Run AFTER scripts/seed.js
|
||||
// and scripts/seed-roadmap.js (or via `pnpm db:setup`).
|
||||
// Destructive in scope: wipes the data tables it owns then re-inserts.
|
||||
// Users (created by seed.js) are kept; new ones are added with INSERT OR
|
||||
// IGNORE. Idempotent: re-running produces the same demo state.
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
|
@ -16,15 +19,24 @@ const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db'
|
|||
const db = new Database(dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const existing = db.prepare('SELECT COUNT(*) AS n FROM pulses').get().n;
|
||||
if (existing > 0) {
|
||||
console.log(` demo data already present (${existing} pulse(s)) — skipping.`);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
// ── Wipe the tables this seed owns ─────────────────────────────────
|
||||
db.exec(`
|
||||
DELETE FROM activity;
|
||||
DELETE FROM attendance;
|
||||
DELETE FROM reactions;
|
||||
DELETE FROM replies;
|
||||
DELETE FROM contributions;
|
||||
DELETE FROM roadmap_attributions;
|
||||
DELETE FROM roadmap_items;
|
||||
DELETE FROM votes;
|
||||
DELETE FROM pulses;
|
||||
DELETE FROM dispatches;
|
||||
DELETE FROM events;
|
||||
`);
|
||||
|
||||
const users = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
|
||||
const byRole = (r) => users.find(u => u.role === r);
|
||||
// ── Locate canonical users from seed.js ────────────────────────────
|
||||
const allUsers = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
|
||||
const byRole = (r) => allUsers.find(u => u.role === r);
|
||||
const mette = byRole('pilot');
|
||||
const lars = byRole('cab');
|
||||
const jon = byRole('fenja');
|
||||
|
|
@ -34,105 +46,297 @@ if (!mette || !lars || !jon) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Backdate Lars's cab membership to give realistic tenure on /pulse
|
||||
db.prepare(`UPDATE users SET cab_joined_date = date('now', '-2 years', '-4 months') WHERE id = ?`).run(lars.id);
|
||||
// Mark all three as recently seen so the "online now" chip strip has content
|
||||
// (current viewer is excluded from "others online" — see /pulse)
|
||||
db.prepare(`UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?, ?)`)
|
||||
.run(lars.id, mette.id, jon.id);
|
||||
// ── Add 3 additional CAB members, then populate metadata on all 4 ──
|
||||
const ROUNDS = 10;
|
||||
const hash = bcrypt.hashSync('cab123', ROUNDS);
|
||||
|
||||
function kebab(s) {
|
||||
return s.toLowerCase()
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
const newCabs = [
|
||||
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
|
||||
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
|
||||
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
|
||||
];
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT OR IGNORE INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date)
|
||||
VALUES (?, ?, ?, ?, 'cab', ?, NULL)
|
||||
`);
|
||||
for (const c of newCabs) {
|
||||
insertUser.run(c.email, hash, c.name, c.org, kebab(c.name));
|
||||
}
|
||||
|
||||
// Allocate member_numbers in member-since order, tiebreak id asc.
|
||||
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
|
||||
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
|
||||
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
|
||||
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
|
||||
|
||||
const setCabMeta = db.prepare(`
|
||||
UPDATE users
|
||||
SET cab_joined_date = date('now', ?),
|
||||
title = ?,
|
||||
pull_quote = ?,
|
||||
focus_tags = ?,
|
||||
last_seen_at = datetime('now', '-2 minutes')
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const cabMeta = {
|
||||
'lars@virk2.dk': {
|
||||
title: 'Senior Adviser, Operational Risk',
|
||||
pull_quote: 'A model is only as auditable as the chain of evidence behind it. That chain is the work.',
|
||||
focus_tags: ['Risk', 'Audit trail', 'GDPR'],
|
||||
},
|
||||
'anna@virk3.dk': {
|
||||
title: 'Director of Digital Services',
|
||||
pull_quote: 'Municipalities can\'t outsource sovereignty. We need tools that assume that.',
|
||||
focus_tags: ['Public sector', 'Sovereignty'],
|
||||
},
|
||||
'soren@virk4.dk': {
|
||||
title: 'Head of Data Engineering',
|
||||
pull_quote: 'Make it boring to deploy and surprising to query.',
|
||||
focus_tags: ['Infrastructure', 'Telemetry'],
|
||||
},
|
||||
'henriette@virk5.dk': {
|
||||
title: 'Lead Counsel, Compliance',
|
||||
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
|
||||
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
|
||||
},
|
||||
};
|
||||
|
||||
for (const u of cabRows) {
|
||||
const m = cabMeta[u.email] ?? null;
|
||||
if (!m) continue;
|
||||
const weeks = tenureWeeks[u.email] ?? 0;
|
||||
setCabMeta.run(`-${weeks} weeks`, m.title, m.pull_quote, JSON.stringify(m.focus_tags), u.id);
|
||||
}
|
||||
|
||||
// Allocate member_numbers using the SQL backfill from migration 0004,
|
||||
// but only for cab rows that still lack one (idempotent).
|
||||
db.exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (ORDER BY COALESCE(cab_joined_date, created_at) ASC, id ASC) AS rn
|
||||
FROM users WHERE role = 'cab'
|
||||
)
|
||||
UPDATE users
|
||||
SET member_number = (SELECT rn FROM ranked WHERE ranked.id = users.id)
|
||||
WHERE role = 'cab' AND member_number IS NULL;
|
||||
`);
|
||||
|
||||
// Mark Mette + Jonathan recently seen too (for the avatar pile on /events)
|
||||
db.prepare("UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?)").run(mette.id, jon.id);
|
||||
|
||||
// Re-fetch cab rows ordered by member_number
|
||||
const cabs = db.prepare("SELECT id, name, email, member_number FROM users WHERE role = 'cab' AND active = 1 ORDER BY member_number ASC").all();
|
||||
console.log(` cab members: ${cabs.map(c => `${c.name} #${c.member_number}`).join(', ')}`);
|
||||
|
||||
// ── Helper: SQL datetime string at offset seconds from now ─────────
|
||||
const nowIso = (offsetSeconds = 0) => {
|
||||
const d = new Date(Date.now() + offsetSeconds * 1000);
|
||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
};
|
||||
|
||||
// ── Pulse: open now, closes in 5 days ────────────────────────────────
|
||||
const opensAt = nowIso(-3600); // opened an hour ago
|
||||
const closesAt = nowIso(5 * 24 * 3600); // closes in 5 days
|
||||
const options = [
|
||||
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
|
||||
const pulseOptions = [
|
||||
'Locking down on-prem deployment first',
|
||||
'Pushing the traceability layer to GA',
|
||||
'Going wide on document ingestion',
|
||||
'Building the agentic query loop',
|
||||
];
|
||||
|
||||
const pulseId = db.prepare(`
|
||||
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
'Which milestone should we anchor Q3 around?',
|
||||
'Council input on this directly shapes what the team works on in July–September. Read the roadmap before voting.',
|
||||
JSON.stringify(options),
|
||||
opensAt,
|
||||
closesAt,
|
||||
'open',
|
||||
jon.id,
|
||||
JSON.stringify(pulseOptions),
|
||||
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
|
||||
).lastInsertRowid;
|
||||
|
||||
// Lars votes for the traceability option
|
||||
// 2 votes from cabs[0] and cabs[1]
|
||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||
.run(pulseId, lars.id, 1, nowIso(-2 * 3600));
|
||||
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
|
||||
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
|
||||
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
|
||||
|
||||
// ── Roadmap: mark "Traceability layer" as shipping, attribute to Lars ──
|
||||
const traceability = db.prepare("SELECT id FROM roadmap_items WHERE title LIKE 'Traceability%'").get();
|
||||
if (traceability) {
|
||||
db.prepare(`UPDATE roadmap_items SET status = 'shipping', shipped_at = datetime('now', '-2 days'), target = 'Live now' WHERE id = ?`).run(traceability.id);
|
||||
db.prepare('INSERT OR IGNORE INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)').run(traceability.id, lars.id);
|
||||
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
|
||||
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: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
|
||||
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
|
||||
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, shipped_at: null, attributed: [] },
|
||||
];
|
||||
|
||||
const insertRoad = db.prepare(`
|
||||
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`);
|
||||
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
|
||||
for (const r of roadmap) {
|
||||
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
|
||||
for (const uid of r.attributed) insertAttr.run(id, uid);
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────
|
||||
const dinnerStart = nowIso(38 * 24 * 3600); // ~5.5 weeks out
|
||||
db.prepare(`
|
||||
INSERT INTO events (slug, title, kind, description, location, starts_at, capacity, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
'kickoff-dinner-2026-06',
|
||||
'Council kickoff dinner',
|
||||
'dinner',
|
||||
// ── Contributions: 3 entries; most recent gets 3 reactions ─────────
|
||||
const contribs = [
|
||||
{ user_id: cabs[2].id, type: 'idea',
|
||||
body_md: 'What if we let council members write **inline annotations** on the roadmap items they shaped? A trail of "here\'s what I pushed for."',
|
||||
when: nowIso(-2 * 60 * 60), reactors: [cabs[0].id, cabs[1].id, jon.id] },
|
||||
{ user_id: cabs[1].id, type: 'question',
|
||||
body_md: 'How will the traceability layer handle documents with conflicting metadata across versions?',
|
||||
when: nowIso(-2 * 24 * 60 * 60) },
|
||||
{ user_id: cabs[0].id, type: 'inspiration',
|
||||
body_md: 'A piece on **institutional memory** in regulated industries — it lines up almost exactly with what we\'re trying to build.',
|
||||
when: nowIso(-9 * 24 * 60 * 60) },
|
||||
];
|
||||
const insertContrib = db.prepare(`
|
||||
INSERT INTO contributions (user_id, type, body_md, created_at) VALUES (?,?,?,?)
|
||||
`);
|
||||
const insertReact = db.prepare('INSERT INTO reactions (user_id, contribution_id) VALUES (?,?)');
|
||||
for (const c of contribs) {
|
||||
const id = Number(insertContrib.run(c.user_id, c.type, c.body_md, c.when).lastInsertRowid);
|
||||
for (const uid of c.reactors ?? []) insertReact.run(uid, id);
|
||||
}
|
||||
|
||||
// ── Dispatches: 4 published at staggered ages ──────────────────────
|
||||
const dispatchSeed = [
|
||||
{ kind: 'decision', ageDays: 2,
|
||||
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.',
|
||||
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.
|
||||
|
||||
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. Everything else is parked. The agentic query work moves up a quarter to fill the gap.
|
||||
|
||||
We'll revisit public cloud in Q4 once the on-prem story is boring.`,
|
||||
},
|
||||
{ kind: 'behind_the_scenes', ageDays: 5,
|
||||
title: 'A morning at the council kickoff',
|
||||
excerpt: 'Four members in the room, two on video. The whiteboard ended up with three lists: must-have, would-be-nice, do-not-build.',
|
||||
body: `Four council members in the room, two on video. The kickoff meeting was meant to be 90 minutes. It went four hours.
|
||||
|
||||
The whiteboard ended up with three lists: must-have, would-be-nice, and — the most interesting one — do-not-build.
|
||||
|
||||
Henriette pushed back hard on the "agent that emails on your behalf" pattern. "I don't want a system speaking on my legal team's behalf. Ever." That note alone reshaped a whole feature.
|
||||
|
||||
Photos to come, with permission.`,
|
||||
},
|
||||
{ kind: 'update', ageDays: 9,
|
||||
title: 'Document ingestion is now feature-complete in beta',
|
||||
excerpt: 'PDF, Word, plain text. Chunking, metadata, deduplication, basic OCR. Three pilots have run it against their corpora.',
|
||||
body: `Document ingestion is feature-complete in beta. PDF, Word, plain text. Chunking, metadata extraction, deduplication, and basic OCR for scanned PDFs.
|
||||
|
||||
Three pilots have now run it against their internal corpora — biggest was 47,000 documents, smallest was 380. Both worked. The 47k run took 8 hours and surfaced some neat edge cases (mostly around tables that span pages).
|
||||
|
||||
Next week we open it to the full pilot group. We'll need notes.`,
|
||||
},
|
||||
{ kind: 'note', ageDays: 12,
|
||||
title: 'Welcome to the council',
|
||||
excerpt: 'A short note to mark the start. This page will fill up with decisions, half-built ideas, and things we have changed our minds about.',
|
||||
body: `This page will fill up with decisions, half-built ideas, and things we have changed our minds about.
|
||||
|
||||
It is not a blog. It is the studio talking to the room — short, dated, signed.`,
|
||||
},
|
||||
];
|
||||
|
||||
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
|
||||
const insertDispatch = db.prepare(`
|
||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,'published',?,?,?)
|
||||
`);
|
||||
for (let i = 0; i < dispatchSeed.length; i += 1) {
|
||||
const d = dispatchSeed[i];
|
||||
const when = nowIso(-d.ageDays * 24 * 60 * 60);
|
||||
const authorId = fenjas[i % fenjas.length].id;
|
||||
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
|
||||
}
|
||||
|
||||
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
|
||||
const insertEvent = db.prepare(`
|
||||
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`);
|
||||
|
||||
insertEvent.run(
|
||||
'kickoff-dinner-2026-06', 'Council kickoff dinner', 'dinner',
|
||||
'A private dinner at the studio. Conversation about what we ship next, no slides.',
|
||||
'Studio, Refshalevej · Copenhagen',
|
||||
dinnerStart,
|
||||
12,
|
||||
nowIso(38 * 24 * 3600), null, 12, null,
|
||||
'Members only', null, null, null,
|
||||
jon.id,
|
||||
);
|
||||
const dinnerId = db.prepare("SELECT id FROM events WHERE slug = 'kickoff-dinner-2026-06'").get().id;
|
||||
const dinnerSlug = 'kickoff-dinner-2026-06';
|
||||
|
||||
const officeHoursStart = nowIso(14 * 24 * 3600); // 2 weeks out
|
||||
db.prepare(`
|
||||
INSERT INTO events (slug, title, kind, description, location, starts_at, created_by)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
'office-hours-2026-05',
|
||||
'Office hours with the founder',
|
||||
'office_hours',
|
||||
'30-minute one-on-one slots. Open agenda. Book one or just drop by.',
|
||||
insertEvent.run(
|
||||
'studio-hours-2026-05', 'Studio hours with Jonathan', 'office_hours',
|
||||
'30-minute slots. Open agenda. Drop in when you\'ve got something to talk through.',
|
||||
'Virtual (link sent after RSVP)',
|
||||
officeHoursStart,
|
||||
nowIso(14 * 24 * 3600), null, null, null,
|
||||
'Council members', '30 minutes', null, null,
|
||||
jon.id,
|
||||
);
|
||||
const officeHoursId = db.prepare("SELECT id FROM events WHERE slug = 'office-hours-2026-05'").get().id;
|
||||
|
||||
// ── Activity rows ─────────────────────────────────────────────────────
|
||||
// Mix of real (Lars's vote, Jonathan's publish, Jonathan's ship) and
|
||||
// hand-crafted demo rows so the ticker has six items to scroll.
|
||||
insertEvent.run(
|
||||
'working-session-2026-06', 'Working session: traceability UX', 'working_session',
|
||||
'Three-person session to walk through the new traceability UI before it lands in beta.',
|
||||
'Studio, Refshalevej · Copenhagen',
|
||||
nowIso(21 * 24 * 3600), null, 6, null,
|
||||
'Council + Fenja team', '90 minutes', 'RSVP →', null,
|
||||
jon.id,
|
||||
);
|
||||
|
||||
insertEvent.run(
|
||||
'apr-roundtable-2026', 'April roundtable', 'summit',
|
||||
'A half-day session anchored around the EU AI Act compliance roadmap.',
|
||||
'Studio, Refshalevej · Copenhagen',
|
||||
nowIso(-21 * 24 * 3600), null, 10, null,
|
||||
'Members only', null, null, 'https://example.invalid/notes/april',
|
||||
jon.id,
|
||||
);
|
||||
const aprilSlug = 'apr-roundtable-2026';
|
||||
|
||||
insertEvent.run(
|
||||
'march-launch-dinner', 'Launch dinner', 'dinner',
|
||||
'The first dinner. Where the council was formally introduced.',
|
||||
'Aamanns Etablissement · Copenhagen',
|
||||
nowIso(-52 * 24 * 3600), null, 10, null,
|
||||
'Members only', null, null, null,
|
||||
jon.id,
|
||||
);
|
||||
const marchSlug = 'march-launch-dinner';
|
||||
|
||||
// Past-event RSVPs (drives the "attended_count" on past cards)
|
||||
const insertRsvp = db.prepare(`
|
||||
INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at)
|
||||
VALUES (?, ?, 'event', 'yes', datetime('now'))
|
||||
`);
|
||||
for (const c of cabs) insertRsvp.run(c.id, aprilSlug);
|
||||
for (const c of cabs.slice(0, 3)) insertRsvp.run(c.id, marchSlug);
|
||||
|
||||
// Hero dinner: 1 confirmed so far (Lars) — keeps the avatar pile small at first
|
||||
insertRsvp.run(cabs[0].id, dinnerSlug);
|
||||
|
||||
// ── Activity rows for the (now hidden but still-written) feed ──────
|
||||
const insertActivity = db.prepare(`
|
||||
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
`);
|
||||
|
||||
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
|
||||
insertActivity.run(lars.id, 'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
||||
if (traceability) {
|
||||
insertActivity.run(jon.id, 'roadmap_shipped', 'roadmap', traceability.id, nowIso(-2 * 24 * 3600));
|
||||
}
|
||||
insertActivity.run(lars.id, 'rsvped', 'event', dinnerId, nowIso(-8 * 3600));
|
||||
insertActivity.run(mette.id,'rsvped', 'event', officeHoursId, nowIso(-30 * 60));
|
||||
insertActivity.run(jon.id, 'booked_office_hours', 'event', officeHoursId, nowIso(-1 * 24 * 3600));
|
||||
|
||||
console.log(' demo data seeded:');
|
||||
console.log(` pulse #${pulseId} (open, closes in 5 days)`);
|
||||
if (traceability) console.log(` roadmap #${traceability.id} → shipping, attributed to ${lars.name}`);
|
||||
console.log(` events: kickoff-dinner-2026-06, office-hours-2026-05`);
|
||||
console.log(` activity: 6 rows`);
|
||||
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
|
||||
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60));
|
||||
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
|
||||
|
||||
console.log(' pulse #' + pulseId + ' open, 2 of 4 voted');
|
||||
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
|
||||
console.log(' contributions: 3 (most recent has 3 reactions)');
|
||||
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
|
||||
console.log(' events: dinner + studio hours + working session, 2 past');
|
||||
db.close();
|
||||
|
|
|
|||
|
|
@ -17,14 +17,22 @@ try {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Wipe existing seed data (idempotent)
|
||||
// Wipe existing seed data (idempotent). Order matters: every table that
|
||||
// FK-references users (without ON DELETE CASCADE) must be cleared first.
|
||||
db.exec(`
|
||||
DELETE FROM activity;
|
||||
DELETE FROM votes;
|
||||
DELETE FROM dispatches;
|
||||
DELETE FROM events;
|
||||
DELETE FROM pulses;
|
||||
DELETE FROM roadmap_attributions;
|
||||
DELETE FROM reactions;
|
||||
DELETE FROM replies;
|
||||
DELETE FROM contributions;
|
||||
DELETE FROM attendance;
|
||||
DELETE FROM sessions;
|
||||
DELETE FROM join_requests;
|
||||
DELETE FROM invites;
|
||||
DELETE FROM sessions;
|
||||
DELETE FROM users;
|
||||
`);
|
||||
|
||||
|
|
@ -32,21 +40,21 @@ const ROUNDS = 10;
|
|||
|
||||
const users = [
|
||||
{
|
||||
email: 'mette@ssi.dk',
|
||||
email: 'mette@virk1.dk',
|
||||
password: 'pilot123',
|
||||
name: 'Mette Hansen',
|
||||
organisation: 'Statens Serum Institut',
|
||||
organisation: 'Virksomhed 1',
|
||||
role: 'pilot',
|
||||
},
|
||||
{
|
||||
email: 'lars@rigspolitiet.dk',
|
||||
email: 'lars@virk2.dk',
|
||||
password: 'cab123',
|
||||
name: 'Lars Thomsen',
|
||||
organisation: 'Rigspolitiet',
|
||||
organisation: 'Virksomhed 2',
|
||||
role: 'cab',
|
||||
},
|
||||
{
|
||||
email: 'jonathan@fenja.ai',
|
||||
email: 'jonathan@studio.test',
|
||||
password: 'fenja123',
|
||||
name: 'Jonathan',
|
||||
organisation: 'Fenja AI',
|
||||
|
|
@ -55,14 +63,15 @@ const users = [
|
|||
];
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, name, organisation, role, bio)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (email, password_hash, name, organisation, role, bio, slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const userIds = {};
|
||||
for (const u of users) {
|
||||
const hash = bcrypt.hashSync(u.password, ROUNDS);
|
||||
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, '');
|
||||
const slug = u.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const result = insertUser.run(u.email, hash, u.name, u.organisation, u.role, '', slug);
|
||||
userIds[u.role] = Number(result.lastInsertRowid);
|
||||
console.log(` created user: ${u.name} (${u.role}) — password: ${u.password}`);
|
||||
}
|
||||
|
|
@ -135,7 +144,7 @@ insertAttendance.run(userIds['cab'], '2026-04-25-cab-q2-session', 'no');
|
|||
|
||||
console.log('\n Seed complete.');
|
||||
console.log('\n Test credentials:');
|
||||
console.log(' mette@ssi.dk / pilot123 (pilot)');
|
||||
console.log(' lars@rigspolitiet.dk / cab123 (cab)');
|
||||
console.log(' jonathan@fenja.ai / fenja123 (fenja)');
|
||||
console.log(' mette@virk1.dk / pilot123 (pilot)');
|
||||
console.log(' lars@virk2.dk / cab123 (cab)');
|
||||
console.log(' jonathan@studio.test / fenja123 (fenja)');
|
||||
db.close();
|
||||
|
|
|
|||
56
src/components/AvatarPile.astro
Normal file
56
src/components/AvatarPile.astro
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import Avatar from './Avatar.astro';
|
||||
import type { UserPublic } from '../lib/db';
|
||||
|
||||
interface Props {
|
||||
users: UserPublic[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
/** Border color between overlapping avatars — defaults to --surface for cream surfaces. */
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
const { users, max = 5, size = 22, borderColor = 'var(--surface)' } = Astro.props;
|
||||
|
||||
const shown = users.slice(0, max);
|
||||
const overflow = Math.max(0, users.length - shown.length);
|
||||
---
|
||||
<div class="pile" style={`--pile-size: ${size}px; --pile-border: ${borderColor};`}>
|
||||
{shown.map((u, i) => (
|
||||
<span class="pile-slot" style={`z-index: ${shown.length - i}`}>
|
||||
<Avatar id={u.id} name={u.name} size={size} />
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span class="pile-slot pile-overflow label-sm" aria-label={`${overflow} more`}>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pile-slot {
|
||||
display: inline-flex;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1.5px var(--pile-border);
|
||||
}
|
||||
.pile-slot:not(:first-child) {
|
||||
margin-left: calc(var(--pile-size) * -0.32);
|
||||
}
|
||||
.pile-overflow {
|
||||
width: var(--pile-size);
|
||||
height: var(--pile-size);
|
||||
background: var(--ink-muted);
|
||||
color: var(--ink);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
font-size: calc(var(--pile-size) * 0.36);
|
||||
}
|
||||
</style>
|
||||
175
src/components/admin/DispatchesTab.astro
Normal file
175
src/components/admin/DispatchesTab.astro
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
|
||||
import { fmtDateTime } from '../../lib/markdown';
|
||||
import { dispatchKindLabel } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
dispatches: DispatchWithAuthor[];
|
||||
editing: DispatchWithAuthor | null;
|
||||
fenjaUsers: UserPublic[];
|
||||
currentUserId: number;
|
||||
}
|
||||
|
||||
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
published: 'Published',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
|
||||
const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||
---
|
||||
<div class="tab-content">
|
||||
|
||||
<section class="section">
|
||||
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
|
||||
|
||||
<form method="POST" class="invite-form" novalidate>
|
||||
<input type="hidden" name="action" value={formAction} />
|
||||
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
|
||||
|
||||
<div class="field">
|
||||
<label for="d-title" class="label-sm field-label">Title</label>
|
||||
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="d-kind" class="label-sm field-label">Kind</label>
|
||||
<select id="d-kind" name="kind" class="select body-md" required>
|
||||
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
|
||||
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
|
||||
<select id="d-author" name="author_id" class="select body-md" required>
|
||||
{fenjaUsers.map(u => (
|
||||
<option value={u.id} selected={u.id === defaultAuthorId}>
|
||||
{u.name}{u.title ? ` — ${u.title}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
|
||||
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
|
||||
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="d-status" class="label-sm field-label">Status on save</label>
|
||||
<select id="d-status" name="status" class="select body-md">
|
||||
<option value="draft" selected>Draft (hidden from members)</option>
|
||||
<option value="published">Published (stamps published_at)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
|
||||
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="label-sm section-heading">All dispatches</h2>
|
||||
{dispatches.length === 0 ? (
|
||||
<p class="body-sm empty-msg">No dispatches yet.</p>
|
||||
) : (
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label-sm">Title</th>
|
||||
<th class="label-sm">Kind</th>
|
||||
<th class="label-sm">Author</th>
|
||||
<th class="label-sm">Status</th>
|
||||
<th class="label-sm">Published</th>
|
||||
<th class="label-sm">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dispatches.map(d => (
|
||||
<tr>
|
||||
<td class="body-sm">{d.title}</td>
|
||||
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
|
||||
<td class="body-sm">{d.author_name}</td>
|
||||
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
|
||||
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
|
||||
<td class="action-cell">
|
||||
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
|
||||
{d.status === 'draft' && (
|
||||
<form method="POST" class="inline-form">
|
||||
<input type="hidden" name="action" value="publish_dispatch" />
|
||||
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||
<button type="submit" class="action-link label-sm">Publish</button>
|
||||
</form>
|
||||
)}
|
||||
{d.status === 'published' && (
|
||||
<form method="POST" class="inline-form">
|
||||
<input type="hidden" name="action" value="archive_dispatch" />
|
||||
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||
<button type="submit" class="action-link label-sm">Archive</button>
|
||||
</form>
|
||||
)}
|
||||
<form method="POST" class="inline-form">
|
||||
<input type="hidden" name="action" value="delete_dispatch" />
|
||||
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15em var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
|
||||
.status-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
|
||||
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
|
||||
|
||||
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.action-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--on-surface-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
padding: 0;
|
||||
}
|
||||
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||
</style>
|
||||
|
|
@ -13,9 +13,10 @@ const { events, editing, viewing, viewingRsvps } = Astro.props;
|
|||
|
||||
const KIND_LABEL = {
|
||||
dinner: 'Dinner',
|
||||
office_hours: 'Office hours',
|
||||
office_hours: 'Studio hours',
|
||||
summit: 'Summit',
|
||||
virtual: 'Virtual',
|
||||
working_session: 'Working session',
|
||||
} as const;
|
||||
|
||||
function toInputValue(sql: string | null | undefined): string {
|
||||
|
|
@ -59,7 +60,8 @@ const formAction = editing ? 'update_event' : 'create_event';
|
|||
<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'}>Office hours</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>
|
||||
|
|
@ -84,6 +86,22 @@ const formAction = editing ? 'update_event' : 'create_event';
|
|||
<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">
|
||||
|
|
|
|||
90
src/components/admin/UserEditTab.astro
Normal file
90
src/components/admin/UserEditTab.astro
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
import type { UserPublic } from '../../lib/db';
|
||||
import { readFocusTags } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
member: UserPublic;
|
||||
}
|
||||
|
||||
const { member } = Astro.props;
|
||||
const tagsStr = readFocusTags(member.focus_tags).join(', ');
|
||||
---
|
||||
<div class="tab-content">
|
||||
<section class="section">
|
||||
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
|
||||
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
|
||||
|
||||
<form method="POST" class="invite-form" novalidate>
|
||||
<input type="hidden" name="action" value="update_user_admin" />
|
||||
<input type="hidden" name="user_id" value={member.id} />
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="label-sm field-label">Name</label>
|
||||
<input type="text" class="input body-md" value={member.name} disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label-sm field-label">Email</label>
|
||||
<input type="text" class="input body-md" value={member.email} disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label-sm field-label">Organisation</label>
|
||||
<input type="text" class="input body-md" value={member.organisation} disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
|
||||
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="title" class="label-sm field-label">Job title</label>
|
||||
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
|
||||
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
|
||||
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
|
||||
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary label-sm">Save changes</button>
|
||||
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="body-sm note">
|
||||
Role transitions and deactivation live in the participants table.
|
||||
A member-number is allocated the first time a user becomes CAB and is never reused.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tiny live counter for the 200-char pull-quote field — no framework.
|
||||
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
|
||||
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
|
||||
if (!counter) return;
|
||||
const update = () => { counter.textContent = `${el.value.length} / 200`; };
|
||||
el.addEventListener('input', update);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
|
||||
.note {
|
||||
color: var(--on-surface-muted);
|
||||
margin-top: var(--space-4);
|
||||
max-width: var(--reading-max);
|
||||
}
|
||||
.input:disabled {
|
||||
color: var(--on-surface-muted);
|
||||
background: var(--surface-container-low);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,8 +12,6 @@ const { title, user } = Astro.props;
|
|||
const navLinks = [
|
||||
{ href: '/pulse', label: 'Pulse' },
|
||||
{ href: '/roadmap', label: 'Roadmap' },
|
||||
{ href: '/members', label: 'Members' },
|
||||
{ href: '/events', label: 'Events' },
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
|
|
@ -30,6 +28,8 @@ const year = new Date().getFullYear();
|
|||
<div class="nav-inner">
|
||||
<a href="/" class="wordmark-link" aria-label="Project Bifrost — home">
|
||||
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
|
||||
<span class="wordmark-sep" aria-hidden="true">·</span>
|
||||
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
|
||||
</a>
|
||||
|
||||
<nav class="nav-right" aria-label="Main navigation">
|
||||
|
|
@ -112,16 +112,47 @@ const year = new Date().getFullYear();
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
border-bottom: none;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.wordmark-link:hover {
|
||||
border-bottom: none;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.wordmark {
|
||||
height: 22px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
.wordmark-sep {
|
||||
color: var(--on-surface-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.wordmark-project {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-md);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.wordmark-bifrost {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--pigment-terracotta) 0%,
|
||||
var(--pigment-ochre) 28%,
|
||||
var(--pigment-copper) 54%,
|
||||
var(--pigment-indigo) 78%,
|
||||
var(--pigment-heather) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* ── Nav links ──────────────────────────────────────────────────── */
|
||||
.nav-right {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,24 +2,29 @@
|
|||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import {
|
||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||
createInvite, updateUserRole, deactivateUser,
|
||||
getAllJoinRequests,
|
||||
createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
|
||||
getUserPublicById, getAllJoinRequests,
|
||||
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
|
||||
getAllPulses, getPulseById, getPulseWithCounts,
|
||||
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
|
||||
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
|
||||
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
|
||||
getEventRsvpCount, getEventById,
|
||||
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
|
||||
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
|
||||
recordActivity, getAllActivityForAdmin,
|
||||
} from '../../lib/db';
|
||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||
import { fmtDate } from '../../lib/markdown';
|
||||
import { parseFocusTags } from '../../lib/format';
|
||||
import { notifyPulseOpened } from '../../lib/notify';
|
||||
import PulsesTab from '../../components/admin/PulsesTab.astro';
|
||||
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
|
||||
import EventsTab from '../../components/admin/EventsTab.astro';
|
||||
import ActivityTab from '../../components/admin/ActivityTab.astro';
|
||||
import type { Role, RoadmapStatus, EventKind } from '../../lib/db';
|
||||
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;
|
||||
|
||||
|
|
@ -75,6 +80,50 @@ if (Astro.request.method === 'POST') {
|
|||
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();
|
||||
|
|
@ -229,6 +278,12 @@ 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;
|
||||
|
|
@ -251,6 +306,7 @@ 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.',
|
||||
|
|
@ -263,6 +319,11 @@ const MSGS: Record<string, string> = {
|
|||
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');
|
||||
---
|
||||
|
|
@ -279,6 +340,7 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
<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' }]}>
|
||||
|
|
@ -397,7 +459,11 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
)}
|
||||
|
||||
<!-- Participants tab -->
|
||||
{tab === 'participants' && (
|
||||
{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>
|
||||
|
|
@ -436,7 +502,8 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
<td class="body-sm muted">
|
||||
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
|
||||
</td>
|
||||
<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" />
|
||||
|
|
@ -509,6 +576,10 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
<ActivityTab rows={activityRows} />
|
||||
)}
|
||||
|
||||
{tab === 'dispatches' && (
|
||||
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
|
|
@ -791,4 +862,21 @@ actionMsg = Astro.url.searchParams.get('msg');
|
|||
.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>
|
||||
|
|
|
|||
246
src/pages/dispatches/[slug].astro
Normal file
246
src/pages/dispatches/[slug].astro
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { getDispatchById, getAdjacentDispatches } from '../../lib/db';
|
||||
import {
|
||||
parseDispatchSlug, dispatchSlug, dispatchKindLabel,
|
||||
dispatchKindPigment, roleLabel,
|
||||
} from '../../lib/format';
|
||||
import { renderMd } from '../../lib/markdown';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const slugParam = Astro.params.slug ?? '';
|
||||
const id = parseDispatchSlug(slugParam);
|
||||
|
||||
if (!id) return Astro.redirect('/dispatches');
|
||||
|
||||
const d = getDispatchById(id);
|
||||
if (!d || d.status !== 'published') return Astro.redirect('/dispatches');
|
||||
|
||||
// Canonical-redirect when the slug changes after a rename — id is the authority
|
||||
const canonical = dispatchSlug(d);
|
||||
if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`);
|
||||
|
||||
const { prev, next } = getAdjacentDispatches(d.id);
|
||||
|
||||
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(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
|
||||
}).format(parseUtc(iso));
|
||||
}
|
||||
|
||||
const bodyHtml = renderMd(d.body);
|
||||
---
|
||||
<AppLayout title={d.title} user={user}>
|
||||
<article class="page">
|
||||
|
||||
<a href="/dispatches" class="section-link back-link">← All dispatches</a>
|
||||
|
||||
<header class="head">
|
||||
<div class="head-meta">
|
||||
<span class="kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
|
||||
{dispatchKindLabel(d.kind)}
|
||||
</span>
|
||||
<time class="head-date label-sm" datetime={d.published_at ?? d.created_at}>
|
||||
{fmt(d.published_at ?? d.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 class="title">{d.title}</h1>
|
||||
|
||||
<div class="byline">
|
||||
<Avatar id={d.author_id} name={d.author_name} size={32} />
|
||||
<div class="byline-text">
|
||||
<span class="byline-name">{d.author_name}</span>
|
||||
<span class="byline-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body prose" set:html={bodyHtml} />
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<nav class="adjacent" aria-label="Adjacent dispatches">
|
||||
{prev ? (
|
||||
<a class="adj-card adj-prev" href={`/dispatches/${dispatchSlug(prev)}`}>
|
||||
<span class="adj-direction label-sm">← Previous</span>
|
||||
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(prev.kind)}`}>
|
||||
{dispatchKindLabel(prev.kind)}
|
||||
</span>
|
||||
<span class="adj-title">{prev.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="adj-empty"></span>
|
||||
)}
|
||||
{next ? (
|
||||
<a class="adj-card adj-next" href={`/dispatches/${dispatchSlug(next)}`}>
|
||||
<span class="adj-direction label-sm">Next →</span>
|
||||
<span class="adj-kind-pill" style={`--pill: ${dispatchKindPigment(next.kind)}`}>
|
||||
{dispatchKindLabel(next.kind)}
|
||||
</span>
|
||||
<span class="adj-title">{next.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="adj-empty"></span>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
</article>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.back-link { align-self: flex-start; }
|
||||
|
||||
.head { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
.head-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kind-pill {
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 3px 12px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-date {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.byline-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.byline-name { font-weight: 600; color: var(--on-surface); }
|
||||
.byline-role {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body-lg);
|
||||
line-height: 1.7;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.body :global(p) { margin: 0 0 var(--space-4); }
|
||||
.body :global(h2) {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
margin: var(--space-6) 0 var(--space-3);
|
||||
}
|
||||
.body :global(h3) {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
margin: var(--space-5) 0 var(--space-2);
|
||||
}
|
||||
.body :global(blockquote) {
|
||||
border-left: 2px solid color-mix(in oklab, var(--pigment-terracotta) 40%, transparent);
|
||||
padding-left: var(--space-4);
|
||||
color: var(--on-surface-variant);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
.body :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--surface-container);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.body :global(ul), .body :global(ol) {
|
||||
padding-left: var(--space-5);
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
height: 0.5px;
|
||||
background: var(--surface-card-border);
|
||||
margin: var(--space-6) 0 0;
|
||||
}
|
||||
|
||||
.adjacent {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.adj-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
border-bottom: 0.5px solid var(--surface-card-border);
|
||||
color: inherit;
|
||||
transition: transform 300ms var(--ease-standard);
|
||||
}
|
||||
.adj-card:hover { transform: translateY(-2px); border-bottom-color: var(--surface-card-border); }
|
||||
.adj-next { text-align: right; align-items: flex-end; }
|
||||
|
||||
.adj-direction {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.adj-kind-pill {
|
||||
align-self: flex-start;
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.adj-next .adj-kind-pill { align-self: flex-end; }
|
||||
.adj-title {
|
||||
font-family: var(--font-serif);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.adj-empty {} /* placeholder for missing prev/next slot */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.adjacent { grid-template-columns: 1fr; }
|
||||
.adj-next { text-align: left; align-items: flex-start; }
|
||||
.adj-next .adj-kind-pill { align-self: flex-start; }
|
||||
}
|
||||
</style>
|
||||
179
src/pages/dispatches/index.astro
Normal file
179
src/pages/dispatches/index.astro
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { getLatestPublishedDispatches } from '../../lib/db';
|
||||
import {
|
||||
dispatchSlug, dispatchKindLabel, dispatchKindPigment,
|
||||
dispatchExcerptParas, roleLabel,
|
||||
} from '../../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const dispatches = getLatestPublishedDispatches(200);
|
||||
|
||||
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(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Copenhagen',
|
||||
}).format(parseUtc(iso));
|
||||
}
|
||||
---
|
||||
<AppLayout title="Dispatches" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<header class="head">
|
||||
<h1 class="head-title">Notes from the studio.</h1>
|
||||
<p class="head-sub">Decisions, half-built ideas, and things we've changed our mind about.</p>
|
||||
</header>
|
||||
|
||||
{dispatches.length === 0 ? (
|
||||
<p class="body-md empty">Nothing posted yet.</p>
|
||||
) : (
|
||||
<ul class="d-list">
|
||||
{dispatches.map(d => (
|
||||
<li class="d-row">
|
||||
<a href={`/dispatches/${dispatchSlug(d)}`} class="d-link">
|
||||
<div class="d-byline">
|
||||
<Avatar id={d.author_id} name={d.author_name} size={28} />
|
||||
<span class="d-author-text">
|
||||
<span class="d-author-name">{d.author_name}</span>
|
||||
<span class="d-author-role label-sm">{d.author_title ?? roleLabel(d.author_role)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-body">
|
||||
<header class="d-title-row">
|
||||
<h2 class="d-title">{d.title}</h2>
|
||||
<span class="d-kind-pill" style={`--pill: ${dispatchKindPigment(d.kind)}`}>
|
||||
{dispatchKindLabel(d.kind)}
|
||||
</span>
|
||||
</header>
|
||||
<p class="d-excerpt">{dispatchExcerptParas(d).lead}</p>
|
||||
</div>
|
||||
|
||||
<time class="d-date label-sm" datetime={d.published_at ?? d.created_at}>
|
||||
{fmt(d.published_at ?? d.created_at)}
|
||||
</time>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.head { max-width: 46rem; }
|
||||
.head-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.head-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: var(--text-display-md);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
|
||||
.empty { color: var(--on-surface-muted); }
|
||||
|
||||
.d-list { list-style: none; padding: 0; margin: 0; }
|
||||
.d-row { border-bottom: 0.5px solid var(--surface-card-border); }
|
||||
.d-row:last-child { border-bottom: none; }
|
||||
|
||||
.d-link {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 130px;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-5) var(--space-3);
|
||||
align-items: start;
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
color: inherit;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.d-link:hover {
|
||||
background: color-mix(in oklab, var(--surface-card) 60%, transparent);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.d-byline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
|
||||
.d-author-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.d-author-name {
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.d-author-role {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.d-body { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||
.d-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.d-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.d-kind-pill {
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.d-excerpt {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.d-date {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.d-link {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.d-date { justify-self: start; }
|
||||
}
|
||||
</style>
|
||||
513
src/pages/events.astro
Normal file
513
src/pages/events.astro
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
---
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import AvatarPile from '../components/AvatarPile.astro';
|
||||
import {
|
||||
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
|
||||
getEventRsvpCount, getUserRsvp, setEventRsvp, recordActivity,
|
||||
} from '../lib/db';
|
||||
import { eventKindLabel, defaultActionLabel, pigmentForId } from '../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
// ── POST: RSVP ──────────────────────────────────────────────────────
|
||||
if (Astro.request.method === 'POST') {
|
||||
const data = await Astro.request.formData();
|
||||
const action = String(data.get('action') ?? '');
|
||||
if (action === 'rsvp') {
|
||||
const slug = String(data.get('event_slug') ?? '');
|
||||
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
|
||||
if (slug && ['yes', 'no', 'interested'].includes(status)) {
|
||||
const ev = getEventBySlug(slug);
|
||||
if (ev) {
|
||||
setEventRsvp(user.id, slug, status);
|
||||
recordActivity(user.id, 'rsvped', 'event', ev.id);
|
||||
}
|
||||
}
|
||||
return Astro.redirect('/events');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────
|
||||
const upcoming = getUpcomingEvents(20);
|
||||
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? null;
|
||||
const alsoUpcoming = upcoming.filter(e => e.id !== hero?.id);
|
||||
const past = getPastEvents(8);
|
||||
|
||||
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));
|
||||
}
|
||||
function dayNum(iso: string) { return fmt({ day: 'numeric' }, iso); }
|
||||
function weekday(iso: string) { return fmt({ weekday: 'short' }, iso).toUpperCase(); }
|
||||
function monthShort(iso: string) { return fmt({ month: 'short' }, iso).toUpperCase(); }
|
||||
function timeStr(iso: string) { return fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); }
|
||||
|
||||
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
|
||||
const heroConfirmedCount = heroAttendees.length;
|
||||
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
|
||||
const heroAudience = hero?.audience ?? 'Members only';
|
||||
---
|
||||
<AppLayout title="Events" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<header class="head">
|
||||
<h1 class="head-title">Where the council gathers.</h1>
|
||||
<p class="head-sub">Dinners, working sessions, the occasional summit. Always small, always off the record.</p>
|
||||
</header>
|
||||
|
||||
<!-- ── Hero invitation ─────────────────────────────────────── -->
|
||||
{hero ? (
|
||||
<article class="hero" aria-label={`Next up: ${hero.title}`}>
|
||||
<div class="hero-body">
|
||||
<div class="hero-date">
|
||||
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
|
||||
<span class="hero-day">{dayNum(hero.starts_at)}</span>
|
||||
<span class="hero-month">{monthShort(hero.starts_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-detail">
|
||||
<h2 class="hero-title">{hero.title}</h2>
|
||||
<p class="hero-desc">{hero.description}</p>
|
||||
<p class="hero-meta">{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="hero-foot">
|
||||
<div class="hero-foot-left">
|
||||
<span class="hero-foot-stat">
|
||||
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
|
||||
</span>
|
||||
{heroAttendees.length > 0 && (
|
||||
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form method="POST" class="hero-foot-right">
|
||||
<input type="hidden" name="action" value="rsvp" />
|
||||
<input type="hidden" name="event_slug" value={hero.slug} />
|
||||
{heroMyRsvp === '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="yes" class="hero-cta">Save your seat →</button>
|
||||
)}
|
||||
</form>
|
||||
</footer>
|
||||
</article>
|
||||
) : (
|
||||
<article class="hero hero--empty">
|
||||
<p class="hero-empty-line">
|
||||
Nothing scheduled yet — when we have something, you'll be the first to know.
|
||||
</p>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<!-- ── Also coming up ──────────────────────────────────────── -->
|
||||
{alsoUpcoming.length > 0 && (
|
||||
<section class="also">
|
||||
<ul class="also-list">
|
||||
{alsoUpcoming.map(ev => (
|
||||
<li class="also-row">
|
||||
<div class="also-date">
|
||||
<span class="also-day">{dayNum(ev.starts_at)}</span>
|
||||
<span class="also-month">{monthShort(ev.starts_at)}</span>
|
||||
</div>
|
||||
<div class="also-body">
|
||||
<h3 class="also-title">{ev.title}</h3>
|
||||
<p class="also-meta">
|
||||
{[ev.duration_label, ev.audience, ev.location].filter(Boolean).join(' · ')
|
||||
|| eventKindLabel(ev.kind)}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" class="also-action-form">
|
||||
<input type="hidden" name="action" value="rsvp" />
|
||||
<input type="hidden" name="event_slug" value={ev.slug} />
|
||||
<button type="submit" name="status" value="yes" class="also-action">
|
||||
{ev.action_label ?? defaultActionLabel(ev.kind)}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- ── Past gatherings ─────────────────────────────────────── -->
|
||||
{past.length > 0 && (
|
||||
<section class="past">
|
||||
<ul class="past-grid">
|
||||
{past.map(ev => {
|
||||
const monthCode = monthShort(ev.starts_at);
|
||||
const attended = getEventRsvpCount(ev.slug).going;
|
||||
const hasNotes = !!ev.notes_url;
|
||||
const pigA = pigmentForId(ev.id);
|
||||
const pigB = pigmentForId(ev.id + 1);
|
||||
return (
|
||||
<li class="past-card">
|
||||
{ev.photo_url ? (
|
||||
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
|
||||
) : hasNotes ? (
|
||||
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
|
||||
<span class="past-thumb-month">{monthCode}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
class="past-thumb past-thumb--gradient"
|
||||
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="past-thumb-month">{monthCode}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="past-text">
|
||||
<h3 class="past-title">{ev.title}</h3>
|
||||
<p class="past-meta">{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}{ev.location && ` · ${ev.location}`}</p>
|
||||
<p class="past-foot label-sm">
|
||||
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<a href="/events/past" class="section-link">View all past gatherings →</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
/* ── Head ─────────────────────────────────────────────────────── */
|
||||
.head { max-width: 46rem; }
|
||||
.head-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.head-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: var(--text-display-md);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.head-sub {
|
||||
color: var(--on-surface-variant);
|
||||
margin-top: var(--space-3);
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
background: var(--ink);
|
||||
color: var(--ink-text);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.hero-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-4) 0;
|
||||
border-left: 0 solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
.hero-body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100px;
|
||||
top: 0; bottom: 0;
|
||||
width: 0.5px;
|
||||
background: rgba(232, 224, 208, 0.2);
|
||||
}
|
||||
|
||||
.hero-date { display: flex; flex-direction: column; gap: 2px; }
|
||||
.hero-weekday, .hero-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.hero-day {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.75rem;
|
||||
line-height: 1;
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
.hero-detail { padding-left: var(--space-5); }
|
||||
.hero-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
color: var(--ink-text);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.hero-desc {
|
||||
color: rgba(232, 224, 208, 0.85);
|
||||
margin: 0 0 var(--space-3);
|
||||
max-width: 40rem;
|
||||
}
|
||||
.hero-meta {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-foot {
|
||||
border-top: 0.5px solid rgba(232, 224, 208, 0.2);
|
||||
padding-top: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.hero-foot-stat {
|
||||
color: var(--ink-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
||||
|
||||
.hero-cta {
|
||||
background: var(--ink-text);
|
||||
color: var(--ink);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.hero-cta:hover { opacity: 0.85; }
|
||||
|
||||
.hero-confirmed {
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 10px 16px;
|
||||
border: 0.5px solid rgba(232, 224, 208, 0.4);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.hero-change {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ink-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hero--empty {
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
}
|
||||
.hero-empty-line {
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.25rem;
|
||||
margin: auto;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
/* ── Section eyebrow shared ──────────────────────────────────── */
|
||||
.section-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Also coming up ──────────────────────────────────────────── */
|
||||
.also-list { list-style: none; padding: 0; margin: 0; }
|
||||
.also-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr 110px;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 0.5px solid var(--surface-card-border);
|
||||
}
|
||||
.also-row:last-child { border-bottom: none; }
|
||||
|
||||
.also-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.also-day {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.also-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.also-body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.also-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.also-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
|
||||
|
||||
.also-action-form { justify-self: end; }
|
||||
.also-action {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pigment-terracotta);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 500;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.also-action:hover { opacity: 0.85; }
|
||||
|
||||
/* ── Past gatherings ─────────────────────────────────────────── */
|
||||
.past-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.past-all {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
.past-all:hover { opacity: 0.85; border-bottom: none; }
|
||||
|
||||
.past-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.past-card {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
.past-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
.past-thumb--notes {
|
||||
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
|
||||
color: var(--pigment-copper);
|
||||
}
|
||||
.past-thumb-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: rgba(250, 246, 238, 0.6);
|
||||
}
|
||||
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
|
||||
|
||||
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.past-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
|
||||
.past-foot {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero-body { grid-template-columns: 1fr; }
|
||||
.hero-body::after { display: none; }
|
||||
.past-grid { grid-template-columns: 1fr; }
|
||||
.also-row { grid-template-columns: 60px 1fr; }
|
||||
.also-action-form { grid-column: 1 / -1; justify-self: start; padding-top: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
159
src/pages/events/past.astro
Normal file
159
src/pages/events/past.astro
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
import AppLayout from '../../layouts/AppLayout.astro';
|
||||
import { getPastEvents, getEventRsvpCount } from '../../lib/db';
|
||||
import { pigmentForId } from '../../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const past = getPastEvents(500);
|
||||
|
||||
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));
|
||||
}
|
||||
---
|
||||
<AppLayout title="Past gatherings" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<header class="head">
|
||||
<h1 class="head-title">The archive.</h1>
|
||||
<p class="head-sub">Everything the council has gathered around so far.</p>
|
||||
<a href="/events" class="section-link back-link">← Back to upcoming</a>
|
||||
</header>
|
||||
|
||||
{past.length === 0 ? (
|
||||
<p class="body-md empty">No past events yet.</p>
|
||||
) : (
|
||||
<ul class="past-list">
|
||||
{past.map(ev => {
|
||||
const monthCode = fmt({ month: 'short' }, ev.starts_at).toUpperCase();
|
||||
const attended = getEventRsvpCount(ev.slug).going;
|
||||
const hasNotes = !!ev.notes_url;
|
||||
const pigA = pigmentForId(ev.id);
|
||||
const pigB = pigmentForId(ev.id + 1);
|
||||
return (
|
||||
<li class="past-card">
|
||||
{ev.photo_url ? (
|
||||
<img class="past-thumb" src={ev.photo_url} alt="" loading="lazy" />
|
||||
) : hasNotes ? (
|
||||
<a href={ev.notes_url!} class="past-thumb past-thumb--notes" aria-label="Read the notes">
|
||||
<span class="past-thumb-month">{monthCode}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
class="past-thumb past-thumb--gradient"
|
||||
style={`background: linear-gradient(135deg, ${pigA.hex}, ${pigB.hex});`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="past-thumb-month">{monthCode}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="past-text">
|
||||
<h3 class="past-title">{ev.title}</h3>
|
||||
<p class="past-meta">
|
||||
{fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}
|
||||
{ev.location && ` · ${ev.location}`}
|
||||
</p>
|
||||
<p class="past-foot label-sm">
|
||||
{attended} attended · {hasNotes ? 'Notes shared' : 'No notes'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.head { max-width: 46rem; }
|
||||
.head-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.head-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: var(--text-display-md);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.head-sub { color: var(--on-surface-variant); margin-top: var(--space-3); max-width: 32rem; }
|
||||
.back-link { margin-top: var(--space-4); }
|
||||
|
||||
.empty { color: var(--on-surface-muted); }
|
||||
|
||||
.past-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.past-card {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
.past-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
}
|
||||
.past-thumb--notes {
|
||||
background: color-mix(in oklab, var(--pigment-copper) 18%, transparent);
|
||||
color: var(--pigment-copper);
|
||||
}
|
||||
.past-thumb-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: rgba(250, 246, 238, 0.6);
|
||||
}
|
||||
.past-thumb--notes .past-thumb-month { color: var(--pigment-copper); }
|
||||
|
||||
.past-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.past-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.past-meta { font-size: 0.75rem; color: var(--on-surface-variant); margin: 0; }
|
||||
.past-foot {
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.past-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,8 +19,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
<div class="page">
|
||||
|
||||
<header class="members-head">
|
||||
<p class="label-sm members-eyebrow">Members</p>
|
||||
<h1 class="members-title"><em>The council.</em></h1>
|
||||
<h1 class="members-title">The council.</h1>
|
||||
<p class="members-sub">
|
||||
An invited circle of operators shaping what Project Bifrost becomes.
|
||||
</p>
|
||||
|
|
@ -82,13 +81,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
|
||||
.members-head { margin-bottom: var(--space-6); max-width: 46rem; }
|
||||
|
||||
.members-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.members-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
|
|
@ -98,7 +90,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.members-title em { font-style: italic; }
|
||||
|
||||
.members-sub {
|
||||
color: var(--on-surface-variant);
|
||||
|
|
@ -140,7 +131,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
|
||||
.m-name {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.2;
|
||||
|
|
@ -174,8 +164,7 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
|
|||
padding-left: 12px;
|
||||
border-left: 2px solid color-mix(in oklab, var(--row-pigment) 40%, transparent);
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface);
|
||||
max-width: 38rem;
|
||||
|
|
|
|||
|
|
@ -1,31 +1,32 @@
|
|||
---
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import MembershipCard from '../components/MembershipCard.astro';
|
||||
import DispatchesSection from '../components/DispatchesSection.astro';
|
||||
import RecentlyFromTheCouncil from '../components/RecentlyFromTheCouncil.astro';
|
||||
import Avatar from '../components/Avatar.astro';
|
||||
import AvatarPile from '../components/AvatarPile.astro';
|
||||
import {
|
||||
getOpenPulse, getPulseWithCounts, castVote, recordActivity,
|
||||
getPulseById, getAllRoadmapItems, getUpcomingEvents,
|
||||
countCabMembers, getUserVote,
|
||||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
||||
getUserRsvp, setEventRsvp, recordActivity,
|
||||
getAllRoadmapItems, getLatestPublishedDispatches, getAllCabMembers,
|
||||
} from '../lib/db';
|
||||
import { pulseDateLabel, timeOfDay, tenureSince, voteCountSentence } from '../lib/format';
|
||||
import {
|
||||
pulseDateLabel, timeOfDay, tenureSince, relativeTime,
|
||||
eventKindLabel,
|
||||
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
|
||||
} from '../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
// ── POST: cast vote ────────────────────────────────────────────────
|
||||
// ── POST: RSVP from the hero card ──────────────────────────────────
|
||||
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 existing = getUserVote(pulseId, user.id);
|
||||
if (existing === null) {
|
||||
castVote(pulseId, user.id, optionIndex);
|
||||
recordActivity(user.id, 'voted', 'pulse', pulseId);
|
||||
if (action === 'rsvp') {
|
||||
const slug = String(data.get('event_slug') ?? '');
|
||||
const status = String(data.get('status') ?? '') as 'yes' | 'no' | 'interested';
|
||||
if (slug && ['yes', 'no', 'interested'].includes(status)) {
|
||||
const ev = getEventBySlug(slug);
|
||||
if (ev) {
|
||||
setEventRsvp(user.id, slug, status);
|
||||
recordActivity(user.id, 'rsvped', 'event', ev.id);
|
||||
}
|
||||
}
|
||||
return Astro.redirect('/pulse');
|
||||
|
|
@ -35,40 +36,39 @@ if (Astro.request.method === 'POST') {
|
|||
// ── Greeting ───────────────────────────────────────────────────────
|
||||
const firstName = user.name.split(' ')[0];
|
||||
const greeting = `Good ${timeOfDay()}, ${firstName}.`;
|
||||
const dateLabel = pulseDateLabel();
|
||||
// (date label dropped per the v3 eyebrow-removal pass; tenure line stays inline)
|
||||
|
||||
const tenureAnchor = user.role === 'cab' && user.cab_joined_date
|
||||
? user.cab_joined_date
|
||||
: user.created_at;
|
||||
const tenure = tenureSince(tenureAnchor);
|
||||
|
||||
// ── This week's Pulse ──────────────────────────────────────────────
|
||||
const openPulseRaw = getOpenPulse();
|
||||
const totalMembers = countCabMembers();
|
||||
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
const upcoming = getUpcomingEvents(20);
|
||||
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
|
||||
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
|
||||
|
||||
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
|
||||
function timeLeftLabel(closesAt: string): string {
|
||||
const ms = new Date(closesAt).getTime() - Date.now();
|
||||
if (ms <= 0) return 'closing now';
|
||||
const d = Math.floor(ms / 86400000);
|
||||
if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`;
|
||||
const h = Math.floor(ms / 3600000);
|
||||
if (h >= 1) return `${h} hour${h === 1 ? '' : 's'}`;
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m >= 1) return `${m} minute${m === 1 ? '' : 's'}`;
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${s} seconds`;
|
||||
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 closeDayLabel(closesAt: string): string {
|
||||
const d = new Date(closesAt);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long', timeZone: 'Europe/Copenhagen',
|
||||
}).format(d);
|
||||
function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso));
|
||||
}
|
||||
const dayNum = (iso: string) => fmt({ day: 'numeric' }, iso);
|
||||
const weekday = (iso: string) => fmt({ weekday: 'short' }, iso).toUpperCase();
|
||||
const monthShort = (iso: string) => fmt({ month: 'short' }, iso).toUpperCase();
|
||||
const timeStr = (iso: string) => fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso);
|
||||
|
||||
// ── Roadmap preview (3 most-recently-updated items) ────────────────
|
||||
const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : [];
|
||||
const heroConfirmedCount = heroAttendees.length;
|
||||
const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null;
|
||||
|
||||
// ── Latest from Fenja ──────────────────────────────────────────────
|
||||
const [latestDispatch] = getLatestPublishedDispatches(1);
|
||||
const latestPreview = latestDispatch ? dispatchLongPreview(latestDispatch, 520) : '';
|
||||
|
||||
// ── Roadmap preview (3 most-recently-updated items, horizontal) ────
|
||||
const roadmapPreview = getAllRoadmapItems()
|
||||
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
|
||||
.slice(0, 3);
|
||||
|
|
@ -80,7 +80,7 @@ function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string {
|
|||
exploring: 'var(--on-surface-muted)',
|
||||
})[status];
|
||||
}
|
||||
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null; attributed: unknown[] }): string {
|
||||
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null }): string {
|
||||
const target = item.target ? ` · ${item.target}` : '';
|
||||
switch (item.status) {
|
||||
case 'shipping': return `Shipping${target}`;
|
||||
|
|
@ -89,149 +89,151 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
|
|||
}
|
||||
}
|
||||
|
||||
// ── Events row ─────────────────────────────────────────────────────
|
||||
const upcoming = getUpcomingEvents(20);
|
||||
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
|
||||
const nextOfficeHours = upcoming.find(e => e.kind === 'office_hours') ?? null;
|
||||
|
||||
function formatEventDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
|
||||
}).format(new Date(iso)).toUpperCase();
|
||||
}
|
||||
// ── Council members ─────────────────────────────────────────────────
|
||||
const members = getAllCabMembers();
|
||||
---
|
||||
<AppLayout title="Pulse" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Greeting ─────────────────────────────────────────────── -->
|
||||
<section class="cascade greeting">
|
||||
<p class="label-sm date-label">{dateLabel}</p>
|
||||
<h1 class="greeting-line">
|
||||
<span class="greeting-italic">{greeting}</span>
|
||||
</h1>
|
||||
<h1 class="greeting-line">{greeting}</h1>
|
||||
<p class="greeting-sub body-md">
|
||||
You've been a member for <em>{tenure}</em>. The team is reading every note you leave.
|
||||
You've been a member for {tenure}. The team is reading every note you leave.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── This week's Pulse ────────────────────────────────────── -->
|
||||
<section class="cascade pulse-card">
|
||||
{openPulse ? (
|
||||
<>
|
||||
<div class="pulse-meta">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span class="label-sm pulse-label">This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)}</span>
|
||||
<!-- ── Events (--ink card with hero + bundled coming-up + see all) -->
|
||||
{hero ? (
|
||||
<section class="cascade events-card" aria-label="Events">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="hero-body">
|
||||
<div class="hero-date">
|
||||
<span class="hero-weekday">{weekday(hero.starts_at)}</span>
|
||||
<span class="hero-day">{dayNum(hero.starts_at)}</span>
|
||||
<span class="hero-month">{monthShort(hero.starts_at)}</span>
|
||||
</div>
|
||||
<p class="pulse-question">{openPulse.question}</p>
|
||||
{openPulse.context && <p class="pulse-context body-md">{openPulse.context}</p>}
|
||||
|
||||
<form method="POST" class="pulse-options" novalidate>
|
||||
<input type="hidden" name="action" value="vote" />
|
||||
<input type="hidden" name="pulse_id" value={openPulse.id} />
|
||||
{openPulse.options.map((opt, i) => {
|
||||
const chosen = openPulse.my_vote === i;
|
||||
const count = openPulse.votes_by_option[i] ?? 0;
|
||||
const pct = openPulse.votes_total > 0 ? (count / openPulse.votes_total) * 100 : 0;
|
||||
const locked = openPulse.my_vote !== null;
|
||||
const letter = String.fromCharCode(65 + i); // A/B/C/D
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
name="option_index"
|
||||
value={i}
|
||||
class:list={['pulse-option', { chosen, locked }]}
|
||||
disabled={locked && !chosen}
|
||||
aria-pressed={chosen}
|
||||
>
|
||||
<span class="pulse-option-letter label-sm">{letter}</span>
|
||||
<span class="pulse-option-text">{opt}</span>
|
||||
{locked && (
|
||||
<span class="pulse-option-bar" aria-hidden="true">
|
||||
<span class="pulse-option-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
|
||||
<p class="pulse-count body-sm">
|
||||
{voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
|
||||
<div class="hero-detail">
|
||||
<h2 class="hero-title">{hero.title}</h2>
|
||||
<p class="hero-desc">{hero.description}</p>
|
||||
<p class="hero-meta">
|
||||
{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="hero-foot">
|
||||
<div class="hero-foot-left">
|
||||
<span class="hero-foot-stat">
|
||||
{hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed
|
||||
</span>
|
||||
{heroAttendees.length > 0 && (
|
||||
<AvatarPile users={heroAttendees} max={5} size={22} borderColor="var(--ink)" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form method="POST" class="hero-foot-right">
|
||||
<input type="hidden" name="action" value="rsvp" />
|
||||
<input type="hidden" name="event_slug" value={hero.slug} />
|
||||
{heroMyRsvp === 'yes' ? (
|
||||
<>
|
||||
<span class="hero-confirmed">You're confirmed ✓</span>
|
||||
<button type="submit" name="status" value="no" class="hero-change">Change</button>
|
||||
</>
|
||||
) : (
|
||||
<div class="pulse-empty">
|
||||
<span class="label-sm pulse-label-muted">This week's pulse</span>
|
||||
<p class="pulse-empty-line">No pulse is open right now. The next one drops soon.</p>
|
||||
</div>
|
||||
<button type="submit" name="status" value="yes" class="hero-cta">Save your seat →</button>
|
||||
)}
|
||||
</section>
|
||||
</form>
|
||||
</footer>
|
||||
|
||||
<!-- ── Roadmap preview + Council mark ───────────────────────── -->
|
||||
<section class="cascade preview-row">
|
||||
<div class="roadmap-preview">
|
||||
<p class="label-sm section-eyebrow">From the roadmap</p>
|
||||
{roadmapPreview.length === 0 ? (
|
||||
<p class="body-sm muted">No roadmap items yet.</p>
|
||||
) : (
|
||||
<ul class="roadmap-list">
|
||||
{roadmapPreview.map(item => (
|
||||
<li class="roadmap-row">
|
||||
<span
|
||||
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
|
||||
style={`background:${roadmapStatusDot(item.status)}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="roadmap-row-text">
|
||||
<p class="roadmap-row-title">{item.title}</p>
|
||||
<p class="roadmap-row-blurb label-sm">{roadmapStatusBlurb(item)}</p>
|
||||
<!-- Bundled coming-up sub-cards (no RSVP buttons) -->
|
||||
{comingUp.length > 0 && (
|
||||
<ul class="coming-up-grid">
|
||||
{comingUp.map(ev => (
|
||||
<li class="coming-up-card">
|
||||
<div class="cu-date">
|
||||
<span class="cu-day">{dayNum(ev.starts_at)}</span>
|
||||
<span class="cu-month">{monthShort(ev.starts_at)}</span>
|
||||
</div>
|
||||
<div class="cu-body">
|
||||
<h3 class="cu-title">{ev.title}</h3>
|
||||
<p class="cu-meta">{[ev.duration_label, ev.audience, eventKindLabel(ev.kind)].filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
|
||||
|
||||
<a href="/events" class="section-link section-link--ink hero-see-all">See all events →</a>
|
||||
|
||||
</section>
|
||||
) : (
|
||||
<section class="cascade events-card events-card--empty">
|
||||
<p class="events-empty-line">Nothing scheduled yet — when we have something, you'll be the first to know.</p>
|
||||
<a href="/events" class="section-link section-link--ink">See all events →</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- ── Latest from Fenja (unboxed, longer excerpt) ──────────── -->
|
||||
{latestDispatch && (
|
||||
<article class="cascade latest-article">
|
||||
<header class="latest-byline">
|
||||
<Avatar id={latestDispatch.author_id} name={latestDispatch.author_name} size={28} />
|
||||
<span class="latest-byline-name">{latestDispatch.author_name}</span>
|
||||
{latestDispatch.author_title && <span class="latest-byline-title">· {latestDispatch.author_title}</span>}
|
||||
<span class="latest-byline-time">{relativeTime(latestDispatch.published_at ?? latestDispatch.created_at)}</span>
|
||||
<span class="latest-kind-pill" style={`--pill: ${dispatchKindPigment(latestDispatch.kind)}`}>
|
||||
{dispatchKindLabel(latestDispatch.kind)}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<h2 class="latest-title">{latestDispatch.title}</h2>
|
||||
<p class="latest-body">{latestPreview}</p>
|
||||
|
||||
<a href={`/dispatches/${dispatchSlug(latestDispatch)}`} class="section-link">Read the full dispatch →</a>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<!-- ── Roadmap — horizontal cards ───────────────────────────── -->
|
||||
{roadmapPreview.length > 0 && (
|
||||
<section class="cascade roadmap-section" aria-label="From the roadmap">
|
||||
<ul class="roadmap-grid">
|
||||
{roadmapPreview.map(item => (
|
||||
<li class="roadmap-card">
|
||||
<span
|
||||
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
|
||||
style={`background:${roadmapStatusDot(item.status)}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="roadmap-card-text">
|
||||
<h3 class="roadmap-card-title">{item.title}</h3>
|
||||
<p class="roadmap-card-blurb">{roadmapStatusBlurb(item)}</p>
|
||||
</div>
|
||||
|
||||
<aside class="membership-slot">
|
||||
<MembershipCard member={user} />
|
||||
</aside>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/roadmap" class="section-link">See the full roadmap →</a>
|
||||
</section>
|
||||
|
||||
<!-- ── Dispatches ───────────────────────────────────────────── -->
|
||||
<section class="cascade">
|
||||
<DispatchesSection limit={4} />
|
||||
</section>
|
||||
|
||||
<!-- ── Recently from the council ────────────────────────────── -->
|
||||
<section class="cascade">
|
||||
<RecentlyFromTheCouncil />
|
||||
</section>
|
||||
|
||||
<!-- ── Event row ────────────────────────────────────────────── -->
|
||||
{(nextExclusive || nextOfficeHours) && (
|
||||
<section class="cascade event-row">
|
||||
{nextExclusive && (
|
||||
<article class="event-card event-card--dark">
|
||||
<p class="label-sm event-eyebrow event-eyebrow--light">
|
||||
Members only · {formatEventDate(nextExclusive.starts_at)}
|
||||
</p>
|
||||
<h3 class="event-title">{nextExclusive.title}</h3>
|
||||
<p class="event-desc">{nextExclusive.description}</p>
|
||||
{nextExclusive.capacity && (
|
||||
<p class="event-scarcity label-sm">{nextExclusive.capacity} seats · invitation by hand</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
{nextOfficeHours && (
|
||||
<article class="event-card event-card--light">
|
||||
<p class="label-sm event-eyebrow">
|
||||
Office hours · {formatEventDate(nextOfficeHours.starts_at)}
|
||||
</p>
|
||||
<h3 class="event-title">{nextOfficeHours.title}</h3>
|
||||
<p class="event-desc">{nextOfficeHours.description}</p>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<!-- ── Council members — larger cards with company ──────────── -->
|
||||
{members.length > 0 && (
|
||||
<section class="cascade council-section" aria-label="The council">
|
||||
<ul class="council-grid">
|
||||
{members.map(m => (
|
||||
<li class="council-card">
|
||||
<Avatar id={m.id} name={m.name} size={56} />
|
||||
<div class="council-card-text">
|
||||
<span class="council-card-name">{m.name}</span>
|
||||
{m.title && <span class="council-card-title">{m.title}</span>}
|
||||
<span class="council-card-org">{m.organisation}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/members" class="section-link">See who our council is made up of →</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
@ -259,23 +261,13 @@ function formatEventDate(iso: string): string {
|
|||
.cascade:nth-child(3) { animation-delay: 200ms; }
|
||||
.cascade:nth-child(4) { animation-delay: 300ms; }
|
||||
.cascade:nth-child(5) { animation-delay: 400ms; }
|
||||
.cascade:nth-child(6) { animation-delay: 500ms; }
|
||||
@keyframes cascade-in {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes cascade-in { to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cascade { opacity: 1; transform: none; animation: none; }
|
||||
}
|
||||
|
||||
/* ── Greeting ─────────────────────────────────────────────────── */
|
||||
.greeting { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
|
||||
.date-label {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.greeting-line {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
|
|
@ -285,288 +277,347 @@ function formatEventDate(iso: string): string {
|
|||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-italic { font-style: italic; }
|
||||
|
||||
.greeting-sub {
|
||||
color: var(--on-surface-variant);
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-sub em { font-style: italic; color: var(--on-surface); }
|
||||
|
||||
/* ── Pulse card ───────────────────────────────────────────────── */
|
||||
.pulse-card {
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
/* ── Events card (--ink) ──────────────────────────────────────── */
|
||||
.events-card {
|
||||
background: var(--ink);
|
||||
color: var(--ink-text);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-7) var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.pulse-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--pigment-terracotta);
|
||||
border-radius: 50%;
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.pulse-label {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pulse-label-muted {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.pulse-question {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 1.375rem;
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.pulse-context {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.pulse-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.pulse-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--background);
|
||||
border: var(--ghost-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: transform 300ms var(--ease-standard),
|
||||
border-color 300ms var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pulse-option:hover:not(.locked) {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--outline);
|
||||
}
|
||||
.pulse-option.chosen {
|
||||
border-color: var(--pigment-terracotta);
|
||||
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
|
||||
}
|
||||
.pulse-option.locked:not(.chosen) {
|
||||
cursor: default;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.pulse-option:disabled { opacity: 0.8; }
|
||||
|
||||
.pulse-option-letter {
|
||||
font-weight: 600;
|
||||
color: var(--on-surface-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
|
||||
|
||||
.pulse-option-text { flex: 1; }
|
||||
|
||||
.pulse-option-bar {
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.pulse-option-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pigment-terracotta);
|
||||
opacity: 0.6;
|
||||
transition: width 600ms var(--ease-standard);
|
||||
}
|
||||
|
||||
.pulse-count {
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.pulse-count strong { color: var(--on-surface); font-weight: 600; }
|
||||
|
||||
.pulse-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pulse-empty-line {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 1.25rem;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Roadmap preview + Membership card ──────────────────────── */
|
||||
.preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-6);
|
||||
align-items: stretch;
|
||||
}
|
||||
.events-card--empty {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
min-height: 160px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.events-empty-line {
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
max-width: 32rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
/* Hero (lighter, fewer italics) */
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: var(--space-7);
|
||||
position: relative;
|
||||
}
|
||||
.hero-body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
top: 0; bottom: 0;
|
||||
width: 0.5px;
|
||||
background: rgba(232, 224, 208, 0.18);
|
||||
}
|
||||
.hero-date { display: flex; flex-direction: column; gap: 4px; }
|
||||
.hero-weekday, .hero-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-4);
|
||||
color: rgba(232, 224, 208, 0.75);
|
||||
}
|
||||
.hero-day {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 2.75rem;
|
||||
line-height: 1;
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.hero-detail { padding-left: var(--space-6); }
|
||||
.hero-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
color: var(--ink-text);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
.hero-desc {
|
||||
color: rgba(232, 224, 208, 0.92);
|
||||
margin: 0 0 var(--space-3);
|
||||
max-width: 50rem;
|
||||
}
|
||||
.hero-meta {
|
||||
color: rgba(232, 224, 208, 0.7);
|
||||
font-size: var(--text-body-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roadmap-preview {
|
||||
/* Hero foot */
|
||||
.hero-foot {
|
||||
border-top: 0.5px solid rgba(232, 224, 208, 0.18);
|
||||
padding-top: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.hero-foot-stat {
|
||||
color: rgba(232, 224, 208, 0.7);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.hero-cta {
|
||||
background: var(--ink-text);
|
||||
color: var(--ink);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.hero-cta:hover { opacity: 0.85; }
|
||||
.hero-confirmed {
|
||||
color: var(--ink-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 10px 16px;
|
||||
border: 0.5px solid rgba(232, 224, 208, 0.4);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.hero-change {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(232, 224, 208, 0.75);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.membership-slot { display: flex; }
|
||||
.membership-slot > * { flex: 1; }
|
||||
|
||||
.roadmap-list {
|
||||
/* Bundled coming-up sub-cards (no RSVP buttons) */
|
||||
.coming-up-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.coming-up-card {
|
||||
background: rgba(232, 224, 208, 0.06);
|
||||
border: 0.5px solid rgba(232, 224, 208, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cu-date { display: flex; flex-direction: column; gap: 2px; min-width: 36px; }
|
||||
.cu-day {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.cu-month {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: rgba(232, 224, 208, 0.7);
|
||||
}
|
||||
.cu-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.cu-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
color: var(--ink-text);
|
||||
margin: 0;
|
||||
}
|
||||
.cu-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 224, 208, 0.65);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-see-all { align-self: flex-start; }
|
||||
|
||||
/* ── Latest from Fenja (unboxed) ──────────────────────────────── */
|
||||
.latest-article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
gap: var(--space-3);
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.roadmap-row {
|
||||
.latest-byline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
border-top: var(--ghost-border);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.latest-byline-name { font-weight: 600; color: var(--on-surface); }
|
||||
.latest-byline-title { color: var(--on-surface-variant); }
|
||||
.latest-byline-time {
|
||||
color: var(--on-surface-muted);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: auto;
|
||||
}
|
||||
.latest-kind-pill {
|
||||
background: color-mix(in oklab, var(--pill) 14%, transparent);
|
||||
color: var(--pill);
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
.latest-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.625rem;
|
||||
line-height: 1.25;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
}
|
||||
.latest-body {
|
||||
color: var(--on-surface);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
font-size: var(--text-body-lg);
|
||||
}
|
||||
.roadmap-row:last-child { border-bottom: var(--ghost-border); }
|
||||
|
||||
/* ── Roadmap horizontal cards ─────────────────────────────────── */
|
||||
.roadmap-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.roadmap-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.roadmap-card {
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 130px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.5; }
|
||||
}
|
||||
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
|
||||
|
||||
.roadmap-row-text { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.roadmap-row-title { margin: 0; font-weight: 500; color: var(--on-surface); }
|
||||
.roadmap-row-blurb {
|
||||
.roadmap-card-text { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.roadmap-card-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.3;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.roadmap-card-blurb {
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.see-all {
|
||||
color: var(--on-surface-variant);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
margin-top: var(--space-3);
|
||||
align-self: flex-start;
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.see-all:hover { color: var(--on-surface); border-bottom: none; }
|
||||
|
||||
/* ── Events row ──────────────────────────────────────────────── */
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
padding: var(--space-8);
|
||||
border-radius: var(--radius-md);
|
||||
/* ── Council cards — larger, with company ─────────────────────── */
|
||||
.council-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
transition: transform 300ms var(--ease-standard);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.event-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.event-card--dark {
|
||||
background: var(--ink);
|
||||
color: var(--ink-text);
|
||||
.council-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.event-card--dark .event-title,
|
||||
.event-card--dark .event-desc,
|
||||
.event-card--dark .event-scarcity {
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
.event-card--light {
|
||||
.council-card {
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6) var(--space-6) var(--space-7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.event-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
.council-card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.event-eyebrow--light { color: var(--ink-muted); }
|
||||
|
||||
.event-title {
|
||||
.council-card-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
line-height: var(--leading-snug);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.2;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.event-desc { margin: 0; }
|
||||
|
||||
.event-scarcity {
|
||||
color: var(--on-surface-muted);
|
||||
.council-card-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.council-card-org {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin: 0;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
/* ── Responsive: collapse 2-col rows on narrow widths ────────── */
|
||||
/* ── Responsive ───────────────────────────────────────────────── */
|
||||
@media (max-width: 880px) {
|
||||
.preview-row, .event-row, .pulse-options { grid-template-columns: 1fr; }
|
||||
.roadmap-grid { grid-template-columns: 1fr; }
|
||||
.hero-body { grid-template-columns: 1fr; }
|
||||
.hero-body::after { display: none; }
|
||||
.hero-detail { padding-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -52,9 +52,8 @@ const horizonColors: Record<string, string> = {
|
|||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<p class="label-sm eyebrow">Roadmap</p>
|
||||
<h1 class="display-md page-title">What we are building.</h1>
|
||||
<p class="lead subtitle">
|
||||
<p class="subtitle">
|
||||
Three horizons. What is in progress now, what comes next,
|
||||
and what is further out. This is the live picture.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -232,6 +232,31 @@ a:hover {
|
|||
.ghost-border { border: var(--ghost-border); }
|
||||
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
||||
|
||||
/* --- Section link — prominent italic serif, placed at the bottom of
|
||||
its respective box or article. See points 8 + 10 in the v3 spec:
|
||||
italics are reserved for links + the Bifrost wordmark. --- */
|
||||
.section-link {
|
||||
display: inline-block;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: var(--text-body-md);
|
||||
color: var(--pigment-terracotta);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.section-link:hover {
|
||||
border-bottom: none;
|
||||
opacity: 0.78;
|
||||
color: var(--pigment-terracotta);
|
||||
}
|
||||
.section-link--ink {
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.section-link--ink:hover {
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
/* --- Focus ring --- */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--secondary);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,6 @@
|
|||
--duration-slow: 420ms;
|
||||
|
||||
/* --- Layout --- */
|
||||
--content-max: 72rem; /* 1152px */
|
||||
--content-max: 83rem; /* 1328px — 15% wider than the original 72rem */
|
||||
--reading-max: 42rem; /* 672px */
|
||||
}
|
||||
|
|
|
|||
43
todo.md
Normal file
43
todo.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Todo
|
||||
|
||||
Deferred from the Phase 2 home-page simplification (commits after `ed2c272`).
|
||||
|
||||
## Recently from the council (the feed)
|
||||
|
||||
Removed from `/pulse` in this pass. Currently no surface for member-authored
|
||||
contributions on the home page.
|
||||
|
||||
The underlying `contributions` table, the `RecentlyFromTheCouncil` component,
|
||||
and the existing `/contribute` page all remain in place — only the home-page
|
||||
embed is gone.
|
||||
|
||||
When we revisit this, options to consider:
|
||||
- Inline a compact "What members are saying" row inside the
|
||||
Roadmap + Latest from Fenja card — single quote, smallest possible
|
||||
treatment, doesn't repeat dispatch real-estate.
|
||||
- A dedicated Voices / Council notes section much further down the page
|
||||
with a stronger editorial frame.
|
||||
- Surface contributions only on the dedicated /contribute page (already
|
||||
exists) and rely on dispatches as the primary "what's moving" signal.
|
||||
|
||||
## This week's pulse voting block
|
||||
|
||||
Removed from `/pulse` in this pass. The `pulses`, `votes`, vote-uniqueness
|
||||
constraint, admin Pulses tab, and the activity `voted` kind all remain — only
|
||||
the on-page voting card is gone.
|
||||
|
||||
**Idea worth trying:** rather than rebuild as its own block, fold polling
|
||||
into the "Latest from Fenja" stream. A dispatch can be tagged as a poll
|
||||
(one of the existing kinds, or a new `poll` kind) and rendered inline with
|
||||
the 2–4 vote options + locked state + result bars. Single editorial channel
|
||||
instead of two competing "what should I read first" surfaces on the home page.
|
||||
|
||||
If we go that way:
|
||||
- Schema: extend `dispatches` with a nullable `pulse_id` foreign key (or
|
||||
embed options on the dispatch directly).
|
||||
- Render: when a dispatch is poll-shaped, its excerpt block becomes the
|
||||
Pulse question + options; the "Read the full dispatch" affordance can
|
||||
stay for context/comments.
|
||||
- Admin: a checkbox on the dispatch form ("Attach an active poll") +
|
||||
inline options inputs. No need for the standalone Pulses tab once this
|
||||
lands — fold it in.
|
||||
Loading…
Add table
Reference in a new issue