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>
This commit is contained in:
parent
4611b687c9
commit
f6e7337c5e
5 changed files with 909 additions and 16 deletions
44
src/components/admin/ActivityTab.astro
Normal file
44
src/components/admin/ActivityTab.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
import type { ActivityRow } from '../../lib/db';
|
||||||
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: ActivityRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = Astro.props;
|
||||||
|
---
|
||||||
|
<div class="tab-content">
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">Recent activity</h2>
|
||||||
|
<p class="body-sm section-note">
|
||||||
|
The raw activity feed — what powers the ticker on /pulse. Read-only debug view.
|
||||||
|
Showing up to 200 most-recent events; the ticker takes the last 12 within 7 days.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p class="body-sm empty-msg">No activity recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="label-sm">When</th>
|
||||||
|
<th class="label-sm">Actor</th>
|
||||||
|
<th class="label-sm">Kind</th>
|
||||||
|
<th class="label-sm">Subject</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(r => (
|
||||||
|
<tr>
|
||||||
|
<td class="body-sm muted">{fmtDateTime(r.created_at)}</td>
|
||||||
|
<td class="body-sm">{r.actor_name} <span class="muted">({r.actor_role})</span></td>
|
||||||
|
<td class="body-sm" style="text-transform:lowercase">{r.kind}</td>
|
||||||
|
<td class="body-sm muted">{r.subject_type} #{r.subject_id}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
200
src/components/admin/EventsTab.astro
Normal file
200
src/components/admin/EventsTab.astro
Normal file
|
|
@ -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';
|
||||||
|
---
|
||||||
|
<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>
|
||||||
261
src/components/admin/PulsesTab.astro
Normal file
261
src/components/admin/PulsesTab.astro
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
draft: 'Draft',
|
||||||
|
open: 'Open',
|
||||||
|
closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Convert SQL UTC date "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DDTHH:MM" for datetime-local input. */
|
||||||
|
function toInputValue(sql: string | null | undefined): string {
|
||||||
|
if (!sql) return '';
|
||||||
|
return sql.replace(' ', 'T').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formAction = editing ? 'update_pulse' : 'create_pulse';
|
||||||
|
const optionsForForm: string[] = editing ? [...editing.options] : [];
|
||||||
|
while (optionsForForm.length < 4) optionsForForm.push('');
|
||||||
|
---
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
{viewing ? (
|
||||||
|
<!-- ── Results view ─────────────────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<a href="/admin?tab=pulses" class="back-link label-sm">← Back to pulses</a>
|
||||||
|
<h2 class="label-sm section-heading">Results — {STATUS_LABEL[viewing.status]}</h2>
|
||||||
|
<p class="pulse-question-display">{viewing.question}</p>
|
||||||
|
{viewing.context && <p class="body-md muted">{viewing.context}</p>}
|
||||||
|
<p class="body-sm muted">Open {fmtDateTime(viewing.opens_at)} → {fmtDateTime(viewing.closes_at)} · {viewing.votes_total} vote{viewing.votes_total === 1 ? '' : 's'}</p>
|
||||||
|
<div class="results-grid">
|
||||||
|
{viewing.options.map((opt, i) => {
|
||||||
|
const count = viewing.votes_by_option[i] ?? 0;
|
||||||
|
const pct = viewing.votes_total > 0 ? (count / viewing.votes_total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div class="result-row">
|
||||||
|
<div class="result-meta">
|
||||||
|
<span class="result-letter label-sm">{String.fromCharCode(65 + i)}</span>
|
||||||
|
<span class="result-text">{opt}</span>
|
||||||
|
<span class="result-count label-sm">{count} ({pct.toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-bar"><span class="result-bar-fill" style={`width:${pct.toFixed(1)}%`}></span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<!-- ── Create / edit form ──────────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">{editing ? 'Edit pulse' : 'New pulse'}</h2>
|
||||||
|
<form method="POST" class="invite-form" novalidate>
|
||||||
|
<input type="hidden" name="action" value={formAction} />
|
||||||
|
{editing && <input type="hidden" name="pulse_id" value={editing.id} />}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="question" class="label-sm field-label">Question</label>
|
||||||
|
<input type="text" id="question" name="question" class="input body-md" required value={editing?.question ?? ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="context" class="label-sm field-label">Context (optional)</label>
|
||||||
|
<textarea id="context" name="context" class="input body-md" rows="3">{editing?.context ?? ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="option-grid">
|
||||||
|
<legend class="label-sm field-label">Options (2–4)</legend>
|
||||||
|
{optionsForForm.map((val, i) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`option_${i}`}
|
||||||
|
placeholder={`Option ${String.fromCharCode(65 + i)}`}
|
||||||
|
class="input body-md"
|
||||||
|
value={val}
|
||||||
|
required={i < 2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="opens_at" class="label-sm field-label">Opens at (UTC)</label>
|
||||||
|
<input type="datetime-local" id="opens_at" name="opens_at" class="input body-md" required value={toInputValue(editing?.opens_at)} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="closes_at" class="label-sm field-label">Closes at (UTC)</label>
|
||||||
|
<input type="datetime-local" id="closes_at" name="closes_at" class="input body-md" required value={toInputValue(editing?.closes_at)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save as draft'}</button>
|
||||||
|
{!editing && (
|
||||||
|
<button type="submit" name="publish" value="1" class="btn-secondary label-sm">Save and publish now</button>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<a href="/admin?tab=pulses" class="action-link label-sm">Cancel</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── List ────────────────────────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">All pulses</h2>
|
||||||
|
{pulses.length === 0 ? (
|
||||||
|
<p class="body-sm empty-msg">No pulses yet.</p>
|
||||||
|
) : (
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="label-sm">Question</th>
|
||||||
|
<th class="label-sm">Status</th>
|
||||||
|
<th class="label-sm">Opens / Closes</th>
|
||||||
|
<th class="label-sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pulses.map(p => (
|
||||||
|
<tr>
|
||||||
|
<td class="body-sm">{p.question}</td>
|
||||||
|
<td class="body-sm"><span class:list={['status-pill', `status-${p.status}`]}>{STATUS_LABEL[p.status]}</span></td>
|
||||||
|
<td class="body-sm muted">{fmtDateTime(p.opens_at)} →<br />{fmtDateTime(p.closes_at)}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<a href={`/admin?tab=pulses&view=${p.id}`} class="action-link label-sm">Results</a>
|
||||||
|
<a href={`/admin?tab=pulses&edit=${p.id}`} class="action-link label-sm">Edit</a>
|
||||||
|
{p.status === 'draft' && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="publish_pulse" />
|
||||||
|
<input type="hidden" name="pulse_id" value={p.id} />
|
||||||
|
<button type="submit" class="action-link label-sm">Publish</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{p.status === 'open' && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="close_pulse" />
|
||||||
|
<input type="hidden" name="pulse_id" value={p.id} />
|
||||||
|
<button type="submit" class="action-link label-sm">Close</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="delete_pulse" />
|
||||||
|
<input type="hidden" name="pulse_id" value={p.id} />
|
||||||
|
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this pulse and all votes?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back-link {
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||||
|
|
||||||
|
.pulse-question-display {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-grid { display: flex; flex-direction: column; gap: var(--space-4); margin-top: var(--space-4); }
|
||||||
|
.result-row { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.result-meta { display: flex; align-items: baseline; gap: var(--space-3); }
|
||||||
|
.result-letter { font-weight: 600; color: var(--on-surface-muted); width: 1.5rem; }
|
||||||
|
.result-text { flex: 1; color: var(--on-surface); }
|
||||||
|
.result-count { color: var(--on-surface-muted); letter-spacing: var(--tracking-wide); }
|
||||||
|
.result-bar { height: 4px; background: var(--surface-container); border-radius: var(--radius-full); overflow: hidden; }
|
||||||
|
.result-bar-fill { display: block; height: 100%; background: var(--pigment-terracotta); opacity: 0.6; }
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.option-grid legend {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: var(--space-2) var(--space-6);
|
||||||
|
background: var(--surface-container);
|
||||||
|
color: var(--on-surface);
|
||||||
|
border: var(--ghost-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--surface-container-high); }
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15em var(--space-3);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
|
||||||
|
.status-open { background: rgba(185, 107, 88, 0.12); color: var(--pigment-terracotta); font-weight: 600; }
|
||||||
|
.status-closed { background: var(--surface-container-low); color: var(--on-surface-muted); }
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.action-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||||
|
</style>
|
||||||
179
src/components/admin/RoadmapTab.astro
Normal file
179
src/components/admin/RoadmapTab.astro
Normal file
|
|
@ -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<Status, RoadmapItemWithAttribution[]> = {
|
||||||
|
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
|
||||||
|
beta: items.filter(i => i.status === 'beta' ).sort((a,b) => a.display_order - b.display_order),
|
||||||
|
exploring: items.filter(i => i.status === 'exploring').sort((a,b) => a.display_order - b.display_order),
|
||||||
|
};
|
||||||
|
---
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<!-- ── Form ──────────────────────────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">{editing ? 'Edit roadmap item' : 'New roadmap item'}</h2>
|
||||||
|
<form method="POST" class="invite-form" novalidate>
|
||||||
|
<input type="hidden" name="action" value={formAction} />
|
||||||
|
{editing && <input type="hidden" name="roadmap_id" value={editing.id} />}
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="title" class="label-sm field-label">Title</label>
|
||||||
|
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="status" class="label-sm field-label">Status</label>
|
||||||
|
<select id="status" name="status" class="select body-md" required>
|
||||||
|
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
|
||||||
|
<option value="beta" selected={editing?.status === 'beta'}>Beta</option>
|
||||||
|
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="target" class="label-sm field-label">Target (free-form, e.g. Q3 2026)</label>
|
||||||
|
<input type="text" id="target" name="target" class="input body-md" value={editing?.target ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="display_order" class="label-sm field-label">Order (within status)</label>
|
||||||
|
<input type="number" id="display_order" name="display_order" class="input body-md" value={editing?.display_order ?? 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="description" class="label-sm field-label">Description</label>
|
||||||
|
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="attribution-grid">
|
||||||
|
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
|
||||||
|
{cabUsers.map(u => (
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" name="attributed_user_ids" value={u.id} checked={attributedSet.has(u.id)} />
|
||||||
|
<span class="body-sm">{u.name} <span class="muted">— {u.organisation}</span></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{cabUsers.length === 0 && <span class="body-sm muted">No council members yet.</span>}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create item'}</button>
|
||||||
|
{editing && <a href="/admin?tab=roadmap" class="action-link label-sm">Cancel</a>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── List by status ────────────────────────────────────────── -->
|
||||||
|
{(['shipping','beta','exploring'] as const).map(status => (
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
|
||||||
|
{grouped[status].length === 0 ? (
|
||||||
|
<p class="body-sm empty-msg">Nothing here yet.</p>
|
||||||
|
) : (
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="label-sm">Title</th>
|
||||||
|
<th class="label-sm">Target</th>
|
||||||
|
<th class="label-sm">Attributed</th>
|
||||||
|
<th class="label-sm">Order</th>
|
||||||
|
<th class="label-sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped[status].map((item, idx) => (
|
||||||
|
<tr>
|
||||||
|
<td class="body-sm">{item.title}</td>
|
||||||
|
<td class="body-sm muted">{item.target ?? '—'}</td>
|
||||||
|
<td class="body-sm muted">{item.attributed.length === 0 ? '—' : item.attributed.map(a => a.name.split(' ')[0]).join(', ')}</td>
|
||||||
|
<td class="body-sm muted">{item.display_order}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
{idx > 0 && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="move_roadmap" />
|
||||||
|
<input type="hidden" name="roadmap_id" value={item.id} />
|
||||||
|
<input type="hidden" name="direction" value="up" />
|
||||||
|
<button type="submit" class="action-link label-sm" aria-label="Move up">↑</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{idx < grouped[status].length - 1 && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="move_roadmap" />
|
||||||
|
<input type="hidden" name="roadmap_id" value={item.id} />
|
||||||
|
<input type="hidden" name="direction" value="down" />
|
||||||
|
<button type="submit" class="action-link label-sm" aria-label="Move down">↓</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<a href={`/admin?tab=roadmap&edit=${item.id}`} class="action-link label-sm">Edit</a>
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="delete_roadmap" />
|
||||||
|
<input type="hidden" name="roadmap_id" value={item.id} />
|
||||||
|
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this roadmap item?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.attribution-grid {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.attribution-grid legend {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.check-row { display: flex; align-items: center; gap: var(--space-2); }
|
||||||
|
|
||||||
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.action-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||||
|
</style>
|
||||||
|
|
@ -4,10 +4,22 @@ import {
|
||||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||||
createInvite, updateUserRole, deactivateUser,
|
createInvite, updateUserRole, deactivateUser,
|
||||||
getAllJoinRequests,
|
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';
|
} from '../../lib/db';
|
||||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||||
import { fmtDate } from '../../lib/markdown';
|
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;
|
const user = Astro.locals.user;
|
||||||
|
|
||||||
|
|
@ -62,12 +74,196 @@ if (Astro.request.method === 'POST') {
|
||||||
const userId = Number(data.get('user_id'));
|
const userId = Number(data.get('user_id'));
|
||||||
if (userId && userId !== user.id) deactivateUser(userId);
|
if (userId && userId !== user.id) deactivateUser(userId);
|
||||||
return Astro.redirect('/admin?tab=participants&msg=deactivated');
|
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 invites = getAllInvites();
|
||||||
const users = getAllUsersPublic();
|
const users = getAllUsersPublic();
|
||||||
const joinRequests = getAllJoinRequests();
|
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<string, string> = {
|
||||||
|
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');
|
actionMsg = Astro.url.searchParams.get('msg');
|
||||||
---
|
---
|
||||||
<AppLayout title="Admin" user={user}>
|
<AppLayout title="Admin" user={user}>
|
||||||
|
|
@ -80,30 +276,27 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<a
|
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
|
||||||
href="/admin?tab=invitations"
|
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
|
||||||
class:list={['tab label-sm', { active: tab === 'invitations' }]}
|
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
|
||||||
>Invitations</a>
|
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
|
||||||
<a
|
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
|
||||||
href="/admin?tab=participants"
|
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
|
||||||
class:list={['tab label-sm', { active: tab === 'participants' }]}
|
|
||||||
>Participants</a>
|
|
||||||
<a
|
|
||||||
href="/admin?tab=join"
|
|
||||||
class:list={['tab label-sm', { active: tab === 'join' }]}
|
|
||||||
>
|
|
||||||
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
|
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin?tab=activity" class:list={['tab label-sm', { active: tab === 'activity' }]}>Activity</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionMsg && (
|
{actionMsg && (
|
||||||
<p class="action-msg body-sm" role="status">
|
<p class="action-msg body-sm" role="status">
|
||||||
{actionMsg === 'revoked' ? 'Invite revoked.' :
|
{MSGS[actionMsg] ?? ''}
|
||||||
actionMsg === 'updated' ? 'Role updated.' :
|
|
||||||
actionMsg === 'deactivated' ? 'User deactivated.' : ''}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p class="form-error body-sm" role="alert">{formError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<!-- Invitations tab -->
|
<!-- Invitations tab -->
|
||||||
{tab === 'invitations' && (
|
{tab === 'invitations' && (
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|
@ -300,6 +493,22 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'pulses' && (
|
||||||
|
<PulsesTab pulses={pulses} editing={pulseEditing} viewing={pulseViewing} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'roadmap' && (
|
||||||
|
<RoadmapTab items={roadmapItems} editing={roadmapEditing} cabUsers={cabUsers} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'events' && (
|
||||||
|
<EventsTab events={events} editing={eventEditing} viewing={eventViewing} viewingRsvps={eventViewingRsvps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'activity' && (
|
||||||
|
<ActivityTab rows={activityRows} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue