project-bifrost-platform/src/components/admin/EventsTab.astro
Jonathan Hvid f6e7337c5e feat(admin): pulses, roadmap, events, activity tabs
Four new tabs added to /admin, matching the existing pattern exactly: ?tab=
querystring, plain HTML POST with hidden action field, redirect-with-?msg=
success path, existing .tabs / .section / .data-table / .form-grid / .input
/ .select / .btn-primary / .danger-btn classes — no new form library, no JS
beyond `confirm()`.

Pulses tab — create / edit / publish / close / delete + a results view
(?view=ID) with per-option vote counts and bar charts. Publish writes the
'pulse_opened' activity row and calls notifyPulseOpened() so members can
be notified the same way once the integration lands.

Roadmap tab — full CRUD + multi-select attribution (checkbox grid of
council + pilot users) + up/down arrow reorder within each status column
(JS-free, swaps display_order with neighbour). Status transition to
'shipping' stamps shipped_at exactly once and writes 'roadmap_shipped'
activity.

Events tab — full CRUD + an RSVP summary view (?view=ID) showing going /
interested / declined counts. Slug is required on create and readonly on
edit (it's the URL handle).

Activity tab — read-only debug table of the last 200 activity rows. Per
your call: shipping with the rest, not optional. Saves hours of "why
isn't the ticker showing X" later.

Tab views extracted to src/components/admin/{Pulses,Roadmap,Events,Activity}Tab.astro
to keep admin/index.astro navigable; the POST handlers and data loading
stay in index.astro as the single dispatch point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:57:44 +02:00

200 lines
7.9 KiB
Text

---
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';
---
<div class="tab-content">
{viewing && viewingRsvps ? (
<section class="section">
<a href="/admin?tab=events" class="back-link label-sm">← Back to events</a>
<h2 class="label-sm section-heading">RSVPs — {viewing.title}</h2>
<p class="body-sm muted">{fmtDateTime(viewing.starts_at)} · {viewing.location}</p>
<dl class="rsvp-summary">
<div><dt class="label-sm">Going</dt><dd class="rsvp-count">{viewingRsvps.going}</dd></div>
<div><dt class="label-sm">Interested</dt><dd class="rsvp-count">{viewingRsvps.interested}</dd></div>
<div><dt class="label-sm">Declined</dt><dd class="rsvp-count">{viewingRsvps.declined}</dd></div>
</dl>
</section>
) : (
<>
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit event' : 'New event'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="event_id" value={editing.id} />}
<div class="form-grid">
<div class="field">
<label for="title" class="label-sm field-label">Title</label>
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="field">
<label for="slug" class="label-sm field-label">Slug (URL)</label>
<input type="text" id="slug" name="slug" class="input body-md" required value={editing?.slug ?? ''} readonly={!!editing} />
</div>
<div class="field">
<label for="kind" class="label-sm field-label">Kind</label>
<select id="kind" name="kind" class="select body-md" required>
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Office hours</option>
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
</select>
</div>
<div class="field">
<label for="location" class="label-sm field-label">Location</label>
<input type="text" id="location" name="location" class="input body-md" value={editing?.location ?? ''} />
</div>
<div class="field">
<label for="starts_at" class="label-sm field-label">Starts at (UTC)</label>
<input type="datetime-local" id="starts_at" name="starts_at" class="input body-md" required value={toInputValue(editing?.starts_at)} />
</div>
<div class="field">
<label for="ends_at" class="label-sm field-label">Ends at (optional)</label>
<input type="datetime-local" id="ends_at" name="ends_at" class="input body-md" value={toInputValue(editing?.ends_at)} />
</div>
<div class="field">
<label for="capacity" class="label-sm field-label">Capacity (optional)</label>
<input type="number" id="capacity" name="capacity" class="input body-md" value={editing?.capacity ?? ''} />
</div>
<div class="field">
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
</div>
</div>
<div class="field">
<label for="description" class="label-sm field-label">Description</label>
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create event'}</button>
{editing && <a href="/admin?tab=events" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All events</h2>
{events.length === 0 ? (
<p class="body-sm empty-msg">No events yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">When</th>
<th class="label-sm">Location</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{events.map(ev => (
<tr>
<td class="body-sm">{ev.title}</td>
<td class="body-sm muted">{KIND_LABEL[ev.kind]}</td>
<td class="body-sm muted">{fmtDateTime(ev.starts_at)}</td>
<td class="body-sm muted">{ev.location || '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=events&view=${ev.id}`} class="action-link label-sm">RSVPs</a>
<a href={`/admin?tab=events&edit=${ev.id}`} class="action-link label-sm">Edit</a>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_event" />
<input type="hidden" name="event_id" value={ev.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this event?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</div>
<style>
.back-link {
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
align-self: flex-start;
}
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.rsvp-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
margin: var(--space-4) 0 0;
}
.rsvp-summary div {
background: var(--surface-container-low);
padding: var(--space-5);
border-radius: var(--radius-md);
}
.rsvp-summary dt {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0 0 var(--space-2) 0;
}
.rsvp-summary dd {
font-family: var(--font-serif);
font-size: 2.5rem;
color: var(--on-surface);
margin: 0;
}
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.action-cell {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>