diff --git a/src/components/admin/ActivityTab.astro b/src/components/admin/ActivityTab.astro new file mode 100644 index 0000000..d4e7fa3 --- /dev/null +++ b/src/components/admin/ActivityTab.astro @@ -0,0 +1,44 @@ +--- +import type { ActivityRow } from '../../lib/db'; +import { fmtDateTime } from '../../lib/markdown'; + +interface Props { + rows: ActivityRow[]; +} + +const { rows } = Astro.props; +--- +
+
+

Recent activity

+

+ The raw activity feed — what powers the ticker on /pulse. Read-only debug view. + Showing up to 200 most-recent events; the ticker takes the last 12 within 7 days. +

+ + {rows.length === 0 ? ( +

No activity recorded yet.

+ ) : ( + + + + + + + + + + + {rows.map(r => ( + + + + + + + ))} + +
WhenActorKindSubject
{fmtDateTime(r.created_at)}{r.actor_name} ({r.actor_role}){r.kind}{r.subject_type} #{r.subject_id}
+ )} +
+
diff --git a/src/components/admin/EventsTab.astro b/src/components/admin/EventsTab.astro new file mode 100644 index 0000000..0d69d24 --- /dev/null +++ b/src/components/admin/EventsTab.astro @@ -0,0 +1,200 @@ +--- +import type { Event } from '../../lib/db'; +import { fmtDateTime } from '../../lib/markdown'; + +interface Props { + events: Event[]; + editing: Event | null; + viewing: Event | null; + viewingRsvps: { going: number; interested: number; declined: number } | null; +} + +const { events, editing, viewing, viewingRsvps } = Astro.props; + +const KIND_LABEL = { + dinner: 'Dinner', + office_hours: 'Office hours', + summit: 'Summit', + virtual: 'Virtual', +} as const; + +function toInputValue(sql: string | null | undefined): string { + if (!sql) return ''; + return sql.replace(' ', 'T').slice(0, 16); +} + +const formAction = editing ? 'update_event' : 'create_event'; +--- +
+ + {viewing && viewingRsvps ? ( +
+ ← Back to events +

RSVPs — {viewing.title}

+

{fmtDateTime(viewing.starts_at)} · {viewing.location}

+
+
Going
{viewingRsvps.going}
+
Interested
{viewingRsvps.interested}
+
Declined
{viewingRsvps.declined}
+
+
+ ) : ( + <> +
+

{editing ? 'Edit event' : 'New event'}

+
+ + {editing && } + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + {editing && Cancel} +
+
+
+ +
+

All events

+ {events.length === 0 ? ( +

No events yet.

+ ) : ( + + + + + + + + + + + + {events.map(ev => ( + + + + + + + + ))} + +
TitleKindWhenLocationActions
{ev.title}{KIND_LABEL[ev.kind]}{fmtDateTime(ev.starts_at)}{ev.location || '—'} + RSVPs + Edit +
+ + + +
+
+ )} +
+ + )} + +
+ + diff --git a/src/components/admin/PulsesTab.astro b/src/components/admin/PulsesTab.astro new file mode 100644 index 0000000..2590c6a --- /dev/null +++ b/src/components/admin/PulsesTab.astro @@ -0,0 +1,261 @@ +--- +import type { PulseRow, PulseWithCounts } from '../../lib/db'; +import { fmtDateTime } from '../../lib/markdown'; + +interface Props { + pulses: PulseRow[]; + editing: PulseRow | null; + viewing: PulseWithCounts | null; +} + +const { pulses, editing, viewing } = Astro.props; + +const STATUS_LABEL: Record = { + draft: 'Draft', + open: 'Open', + closed: 'Closed', +}; + +/** Convert SQL UTC date "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM" for datetime-local input. */ +function toInputValue(sql: string | null | undefined): string { + if (!sql) return ''; + return sql.replace(' ', 'T').slice(0, 16); +} + +const formAction = editing ? 'update_pulse' : 'create_pulse'; +const optionsForForm: string[] = editing ? [...editing.options] : []; +while (optionsForForm.length < 4) optionsForForm.push(''); +--- +
+ + {viewing ? ( + +
+ ← Back to pulses +

Results — {STATUS_LABEL[viewing.status]}

+

{viewing.question}

+ {viewing.context &&

{viewing.context}

} +

Open {fmtDateTime(viewing.opens_at)} → {fmtDateTime(viewing.closes_at)} · {viewing.votes_total} vote{viewing.votes_total === 1 ? '' : 's'}

+
+ {viewing.options.map((opt, i) => { + const count = viewing.votes_by_option[i] ?? 0; + const pct = viewing.votes_total > 0 ? (count / viewing.votes_total) * 100 : 0; + return ( +
+
+ {String.fromCharCode(65 + i)} + {opt} + {count} ({pct.toFixed(0)}%) +
+
+
+ ); + })} +
+
+ ) : ( + <> + +
+

{editing ? 'Edit pulse' : 'New pulse'}

+
+ + {editing && } + +
+ + +
+ +
+ + +
+ +
+ Options (2–4) + {optionsForForm.map((val, i) => ( + + ))} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + {!editing && ( + + )} + {editing && ( + Cancel + )} +
+
+
+ + +
+

All pulses

+ {pulses.length === 0 ? ( +

No pulses yet.

+ ) : ( + + + + + + + + + + + {pulses.map(p => ( + + + + + + + ))} + +
QuestionStatusOpens / ClosesActions
{p.question}{STATUS_LABEL[p.status]}{fmtDateTime(p.opens_at)} →
{fmtDateTime(p.closes_at)}
+ Results + Edit + {p.status === 'draft' && ( +
+ + + +
+ )} + {p.status === 'open' && ( +
+ + + +
+ )} +
+ + + +
+
+ )} +
+ + )} + +
+ + diff --git a/src/components/admin/RoadmapTab.astro b/src/components/admin/RoadmapTab.astro new file mode 100644 index 0000000..33e5e5b --- /dev/null +++ b/src/components/admin/RoadmapTab.astro @@ -0,0 +1,179 @@ +--- +import type { RoadmapItemWithAttribution, UserPublic } from '../../lib/db'; + +interface Props { + items: RoadmapItemWithAttribution[]; + editing: RoadmapItemWithAttribution | null; + cabUsers: UserPublic[]; +} + +const { items, editing, cabUsers } = Astro.props; + +const STATUS_LABEL = { shipping: 'Shipping', beta: 'Beta', exploring: 'Exploring' } as const; + +const formAction = editing ? 'update_roadmap' : 'create_roadmap'; +const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id)); + +// Group items by status for display +type Status = 'shipping' | 'beta' | 'exploring'; +const grouped: Record = { + shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order), + beta: items.filter(i => i.status === 'beta' ).sort((a,b) => a.display_order - b.display_order), + exploring: items.filter(i => i.status === 'exploring').sort((a,b) => a.display_order - b.display_order), +}; +--- +
+ + +
+

{editing ? 'Edit roadmap item' : 'New roadmap item'}

+
+ + {editing && } + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ Attributed members (who shaped this) + {cabUsers.map(u => ( + + ))} + {cabUsers.length === 0 && No council members yet.} +
+ +
+ + {editing && Cancel} +
+
+
+ + + {(['shipping','beta','exploring'] as const).map(status => ( +
+

{STATUS_LABEL[status]} · {grouped[status].length}

+ {grouped[status].length === 0 ? ( +

Nothing here yet.

+ ) : ( + + + + + + + + + + + + {grouped[status].map((item, idx) => ( + + + + + + + + ))} + +
TitleTargetAttributedOrderActions
{item.title}{item.target ?? '—'}{item.attributed.length === 0 ? '—' : item.attributed.map(a => a.name.split(' ')[0]).join(', ')}{item.display_order} + {idx > 0 && ( +
+ + + + +
+ )} + {idx < grouped[status].length - 1 && ( +
+ + + + +
+ )} + Edit +
+ + + +
+
+ )} +
+ ))} + +
+ + diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index 8ae9fb5..e7c8b23 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -4,10 +4,22 @@ import { getAllInvites, getAllUsersPublic, revokeInvite, createInvite, updateUserRole, deactivateUser, getAllJoinRequests, + createPulse, updatePulse, publishPulse, closePulse, deletePulse, + getAllPulses, getPulseById, getPulseWithCounts, + createRoadmapItem, updateRoadmapItem, deleteRoadmapItem, + setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, + createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug, + getEventRsvpCount, getEventById, + recordActivity, getAllActivityForAdmin, } from '../../lib/db'; import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; import { fmtDate } from '../../lib/markdown'; -import type { Role } from '../../lib/db'; +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'; const user = Astro.locals.user; @@ -62,12 +74,196 @@ if (Astro.request.method === 'POST') { const userId = Number(data.get('user_id')); if (userId && userId !== user.id) deactivateUser(userId); return Astro.redirect('/admin?tab=participants&msg=deactivated'); + + // ── Pulses ─────────────────────────────────────────────────── + } else if (action === 'create_pulse' || action === 'update_pulse') { + const question = String(data.get('question') ?? '').trim(); + const context = String(data.get('context') ?? '').trim() || null; + const opens_at = toSqlDate(String(data.get('opens_at') ?? '')); + const closes_at = toSqlDate(String(data.get('closes_at') ?? '')); + const publish = String(data.get('publish') ?? '') === '1'; + const options = [0, 1, 2, 3] + .map(i => String(data.get(`option_${i}`) ?? '').trim()) + .filter(s => s.length > 0); + + if (!question || options.length < 2 || !opens_at || !closes_at) { + formError = 'Question, at least 2 options, and both dates are required.'; + } else if (action === 'create_pulse') { + const id = createPulse({ + question, context, options, opens_at, closes_at, + status: publish ? 'open' : 'draft', + created_by: user.id, + }); + if (publish) { + recordActivity(user.id, 'pulse_opened', 'pulse', id); + const p = getPulseById(id); + if (p) notifyPulseOpened(p); + } + return Astro.redirect('/admin?tab=pulses&msg=pulse_created'); + } else { + const id = Number(data.get('pulse_id')); + if (id) updatePulse(id, { question, context, options, opens_at, closes_at }); + return Astro.redirect('/admin?tab=pulses&msg=pulse_updated'); + } + } else if (action === 'publish_pulse') { + const id = Number(data.get('pulse_id')); + if (id) { + publishPulse(id); + recordActivity(user.id, 'pulse_opened', 'pulse', id); + const p = getPulseById(id); + if (p) notifyPulseOpened(p); + } + return Astro.redirect('/admin?tab=pulses&msg=pulse_published'); + } else if (action === 'close_pulse') { + const id = Number(data.get('pulse_id')); + if (id) closePulse(id); + return Astro.redirect('/admin?tab=pulses&msg=pulse_closed'); + } else if (action === 'delete_pulse') { + const id = Number(data.get('pulse_id')); + if (id) deletePulse(id); + return Astro.redirect('/admin?tab=pulses&msg=pulse_deleted'); + + // ── Roadmap ────────────────────────────────────────────────── + } else if (action === 'create_roadmap' || action === 'update_roadmap') { + const title = String(data.get('title') ?? '').trim(); + const description = String(data.get('description') ?? '').trim(); + const status = String(data.get('status') ?? '') as RoadmapStatus; + const target = String(data.get('target') ?? '').trim() || null; + const displayOrder = Number(data.get('display_order') ?? 0); + const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); + + if (!title || !['shipping','beta','exploring'].includes(status)) { + formError = 'Title and status are required.'; + } else if (action === 'create_roadmap') { + const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder }); + setRoadmapAttributions(id, attributedIds); + if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); + return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created'); + } else { + const id = Number(data.get('roadmap_id')); + if (id) { + const { shippedNow } = updateRoadmapItem(id, { + title, description, status, target, display_order: displayOrder, + }); + setRoadmapAttributions(id, attributedIds); + if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); + } + return Astro.redirect('/admin?tab=roadmap&msg=roadmap_updated'); + } + } else if (action === 'delete_roadmap') { + const id = Number(data.get('roadmap_id')); + if (id) deleteRoadmapItem(id); + return Astro.redirect('/admin?tab=roadmap&msg=roadmap_deleted'); + } else if (action === 'move_roadmap') { + const id = Number(data.get('roadmap_id')); + const dir = String(data.get('direction') ?? ''); + if (id && (dir === 'up' || dir === 'down')) { + moveRoadmapItem(id, dir); + } + return Astro.redirect('/admin?tab=roadmap&msg=roadmap_moved'); + + // ── Events ─────────────────────────────────────────────────── + } else if (action === 'create_event' || action === 'update_event') { + const slug = String(data.get('slug') ?? '').trim().toLowerCase(); + const title = String(data.get('title') ?? '').trim(); + const kind = String(data.get('kind') ?? '') as EventKind; + const description = String(data.get('description') ?? '').trim(); + const location = String(data.get('location') ?? '').trim(); + const starts_at = toSqlDate(String(data.get('starts_at') ?? '')); + const ends_at = String(data.get('ends_at') ?? '').trim() + ? toSqlDate(String(data.get('ends_at') ?? '')) + : null; + const capacity = Number(data.get('capacity') ?? 0) || null; + const photo_url = String(data.get('photo_url') ?? '').trim() || null; + + if (!slug || !title || !starts_at || !['dinner','office_hours','summit','virtual'].includes(kind)) { + formError = 'Slug, title, kind, and start date are required.'; + } else if (action === 'create_event') { + createEvent({ slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, created_by: user.id }); + return Astro.redirect('/admin?tab=events&msg=event_created'); + } else { + const id = Number(data.get('event_id')); + if (id) updateEvent(id, { title, kind, description, location, starts_at, ends_at, capacity, photo_url }); + return Astro.redirect('/admin?tab=events&msg=event_updated'); + } + } else if (action === 'delete_event') { + const id = Number(data.get('event_id')); + if (id) deleteEvent(id); + return Astro.redirect('/admin?tab=events&msg=event_deleted'); } } +/** "2026-05-11T12:00" (datetime-local input) → "2026-05-11 12:00:00" (SQL UTC). */ +function toSqlDate(input: string): string { + if (!input) return ''; + // datetime-local format: YYYY-MM-DDTHH:MM (no timezone). Treat as UTC. + return input.replace('T', ' ') + (input.length === 16 ? ':00' : ''); +} + +/** Swap display_order with the neighbour in the same status column. */ +function moveRoadmapItem(id: number, dir: 'up' | 'down'): void { + const all = getAllRoadmapItems(); + const item = all.find(r => r.id === id); + if (!item) return; + const sameStatus = all + .filter(r => r.status === item.status) + .sort((a, b) => a.display_order - b.display_order || a.id - b.id); + const idx = sameStatus.findIndex(r => r.id === id); + const swapIdx = dir === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= sameStatus.length) return; + const other = sameStatus[swapIdx]; + updateRoadmapItem(item.id, { + title: item.title, description: item.description, status: item.status, + target: item.target, display_order: other.display_order, + }); + updateRoadmapItem(other.id, { + title: other.title, description: other.description, status: other.status, + target: other.target, display_order: item.display_order, + }); +} + const invites = getAllInvites(); const users = getAllUsersPublic(); const joinRequests = getAllJoinRequests(); + +const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; +const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null; + +// Per-tab data +const pulses = tab === 'pulses' ? getAllPulses() : []; +const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null; +const pulseViewing = tab === 'pulses' && viewId ? getPulseWithCounts(viewId, user.id) : null; + +const roadmapItems = tab === 'roadmap' ? getAllRoadmapItems() : []; +const roadmapEditing = tab === 'roadmap' && editId ? getRoadmapItem(editId) : null; +const cabUsers = tab === 'roadmap' ? users.filter(u => u.role === 'cab' || u.role === 'pilot') : []; + +const events = tab === 'events' ? getAllEvents() : []; +const eventEditing = tab === 'events' && editId ? getEventById(editId) : null; +const eventViewing = tab === 'events' && viewId ? getEventById(viewId) : null; +const eventViewingRsvps = tab === 'events' && viewId && eventViewing + ? getEventRsvpCount(eventViewing.slug) + : null; + +const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : []; + +const MSGS: Record = { + revoked: 'Invite revoked.', + updated: 'Role updated.', + deactivated: 'User deactivated.', + pulse_created: 'Pulse saved.', + pulse_updated: 'Pulse updated.', + pulse_published: 'Pulse published — members notified.', + pulse_closed: 'Pulse closed.', + pulse_deleted: 'Pulse deleted.', + roadmap_created: 'Roadmap item saved.', + roadmap_updated: 'Roadmap item updated.', + roadmap_deleted: 'Roadmap item deleted.', + roadmap_moved: 'Roadmap reordered.', + event_created: 'Event saved.', + event_updated: 'Event updated.', + event_deleted: 'Event deleted.', +}; actionMsg = Astro.url.searchParams.get('msg'); --- @@ -80,30 +276,27 @@ actionMsg = Astro.url.searchParams.get('msg'); {actionMsg && (

- {actionMsg === 'revoked' ? 'Invite revoked.' : - actionMsg === 'updated' ? 'Role updated.' : - actionMsg === 'deactivated' ? 'User deactivated.' : ''} + {MSGS[actionMsg] ?? ''}

)} + {formError && ( + + )} + {tab === 'invitations' && (
@@ -300,6 +493,22 @@ actionMsg = Astro.url.searchParams.get('msg');
)} + {tab === 'pulses' && ( + + )} + + {tab === 'roadmap' && ( + + )} + + {tab === 'events' && ( + + )} + + {tab === 'activity' && ( + + )} +