+
+
+
+ {editing ? 'Edit roadmap item' : 'New roadmap item'}
+
+
+
+
+ {(['shipping','beta','exploring'] as const).map(status => (
+
+ {STATUS_LABEL[status]} · {grouped[status].length}
+ {grouped[status].length === 0 ? (
+ Nothing here yet.
+ ) : (
+
+
+
+ Title
+ Target
+ Attributed
+ Order
+ Actions
+
+
+
+ {grouped[status].map((item, idx) => (
+
+ {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
+
+
+
+ Delete
+
+
+
+ ))}
+
+
+ )}
+
+ ))}
+
+
+
+
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