New Dispatches tab — full CRUD matching the existing pattern (?tab= querystring, plain HTML POST, hidden action field, redirect-with-?msg=). Author select is restricted to Fenja-role users (defaults to the current admin). Create form has a status toggle (draft / publish on save). publish_dispatch stamps published_at via the existing helper; archive preserves it. Body is a monospace textarea so admins can see markdown without proportional kerning confusion. Participants tab gains a per-row Edit link. When ?tab=participants&edit=ID is set, the table is replaced by <UserEditTab>: title input, comma- separated focus_tags input (parsed server-side via parseFocusTags), a pull_quote textarea with a 200-char live counter, and a read-only member_number display (set on role transition to cab). The inline role dropdown + deactivate stay on the table. EventsTab — adds audience, duration_label, action_label, notes_url inputs. Kind <select> now labels office_hours as 'Studio hours' and exposes working_session as the new fifth option. Admin action-link / action-cell styles were missing on admin/index.astro (they were defined only inside per-tab components); added to the page stylesheet so the new Participants Edit link inherits the same look. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
882 lines
34 KiB
Text
882 lines
34 KiB
Text
---
|
|
import AppLayout from '../../layouts/AppLayout.astro';
|
|
import {
|
|
getAllInvites, getAllUsersPublic, revokeInvite,
|
|
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 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;
|
|
|
|
// Guard: fenja only
|
|
if (user.role !== 'fenja') {
|
|
return Astro.redirect('/');
|
|
}
|
|
|
|
const tab = Astro.url.searchParams.get('tab') ?? 'invitations';
|
|
|
|
let newInviteToken: string | null = null;
|
|
let formError: string | null = null;
|
|
let actionMsg: string | null = null;
|
|
|
|
if (Astro.request.method === 'POST') {
|
|
const data = await Astro.request.formData();
|
|
const action = String(data.get('action') ?? '');
|
|
|
|
if (action === 'create_invite') {
|
|
const name = String(data.get('name') ?? '').trim();
|
|
const email = String(data.get('email') ?? '').trim().toLowerCase();
|
|
const organisation = String(data.get('organisation') ?? '').trim();
|
|
const role = String(data.get('role') ?? '') as Role;
|
|
|
|
if (!name || !email || !organisation || !['pilot','cab','fenja'].includes(role)) {
|
|
formError = 'All fields are required.';
|
|
} else {
|
|
const { token, tokenHash } = generateInviteToken();
|
|
createInvite({
|
|
token_hash: tokenHash,
|
|
email,
|
|
name,
|
|
organisation,
|
|
role,
|
|
expires_at: inviteExpiresAt(),
|
|
created_by_user_id: user.id,
|
|
});
|
|
newInviteToken = `${Astro.url.origin}/invite/${token}`;
|
|
}
|
|
} else if (action === 'revoke_invite') {
|
|
const id = Number(data.get('invite_id'));
|
|
if (id) revokeInvite(id);
|
|
return Astro.redirect('/admin?tab=invitations&msg=revoked');
|
|
} else if (action === 'change_role') {
|
|
const userId = Number(data.get('user_id'));
|
|
const newRole = String(data.get('role')) as Role;
|
|
if (userId && ['pilot','cab','fenja'].includes(newRole)) {
|
|
updateUserRole(userId, newRole);
|
|
}
|
|
return Astro.redirect('/admin?tab=participants&msg=updated');
|
|
} else if (action === 'deactivate_user') {
|
|
const userId = Number(data.get('user_id'));
|
|
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();
|
|
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;
|
|
|
|
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;
|
|
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.',
|
|
user_updated: 'Member profile updated.',
|
|
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.',
|
|
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');
|
|
---
|
|
<AppLayout title="Admin" user={user}>
|
|
<div class="page">
|
|
|
|
<header class="page-header">
|
|
<p class="label-sm eyebrow">Admin</p>
|
|
<h1 class="display-md page-title">Control panel.</h1>
|
|
</header>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<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' }]}>
|
|
Join requests {joinRequests.length > 0 && <span class="tab-count">{joinRequests.length}</span>}
|
|
</a>
|
|
<a href="/admin?tab=activity" class:list={['tab label-sm', { active: tab === 'activity' }]}>Activity</a>
|
|
</div>
|
|
|
|
{actionMsg && (
|
|
<p class="action-msg body-sm" role="status">
|
|
{MSGS[actionMsg] ?? ''}
|
|
</p>
|
|
)}
|
|
|
|
{formError && (
|
|
<p class="form-error body-sm" role="alert">{formError}</p>
|
|
)}
|
|
|
|
<!-- Invitations tab -->
|
|
{tab === 'invitations' && (
|
|
<div class="tab-content">
|
|
|
|
{/* New invite form */}
|
|
<section class="section">
|
|
<h2 class="label-sm section-heading">Generate invite link</h2>
|
|
|
|
{formError && (
|
|
<p class="form-error body-sm" role="alert">{formError}</p>
|
|
)}
|
|
|
|
{newInviteToken && (
|
|
<div class="invite-result">
|
|
<p class="label-sm invite-result-label">Copy this link and send it personally. It expires in 14 days and is single-use.</p>
|
|
<div class="invite-link-row">
|
|
<code class="invite-link body-sm">{newInviteToken}</code>
|
|
<button
|
|
type="button"
|
|
class="copy-btn label-sm"
|
|
data-copy={newInviteToken}
|
|
onclick="navigator.clipboard.writeText(this.dataset.copy);this.textContent='Copied'"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form method="POST" class="invite-form" novalidate>
|
|
<input type="hidden" name="action" value="create_invite" />
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label for="name" class="label-sm field-label">Name</label>
|
|
<input type="text" id="name" name="name" class="input body-md" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="email" class="label-sm field-label">Email</label>
|
|
<input type="email" id="email" name="email" class="input body-md" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="organisation" class="label-sm field-label">Organisation</label>
|
|
<input type="text" id="organisation" name="organisation" class="input body-md" required />
|
|
</div>
|
|
<div class="field">
|
|
<label for="role" class="label-sm field-label">Role</label>
|
|
<select id="role" name="role" class="select body-md" required>
|
|
<option value="pilot">Pilot</option>
|
|
<option value="cab">CAB</option>
|
|
<option value="fenja">Fenja</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn-primary label-sm">Generate link</button>
|
|
</form>
|
|
</section>
|
|
|
|
{/* Invite table */}
|
|
<section class="section">
|
|
<h2 class="label-sm section-heading">Outstanding invites</h2>
|
|
{invites.filter((i) => !i.used_at).length === 0 ? (
|
|
<p class="body-sm empty-msg">No outstanding invites.</p>
|
|
) : (
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="label-sm">Name</th>
|
|
<th class="label-sm">Email</th>
|
|
<th class="label-sm">Organisation</th>
|
|
<th class="label-sm">Role</th>
|
|
<th class="label-sm">Expires</th>
|
|
<th class="label-sm">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invites.filter((i) => !i.used_at).map((invite) => (
|
|
<tr>
|
|
<td class="body-sm">{invite.name}</td>
|
|
<td class="body-sm">{invite.email}</td>
|
|
<td class="body-sm">{invite.organisation}</td>
|
|
<td class="body-sm" style="text-transform:capitalize">{invite.role}</td>
|
|
<td class="body-sm">{fmtDate(invite.expires_at)}</td>
|
|
<td>
|
|
<form method="POST" class="inline-form">
|
|
<input type="hidden" name="action" value="revoke_invite" />
|
|
<input type="hidden" name="invite_id" value={invite.id} />
|
|
<button type="submit" class="danger-btn label-sm">Revoke</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
|
|
</div>
|
|
)}
|
|
|
|
<!-- Participants tab -->
|
|
{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>
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="label-sm">Name</th>
|
|
<th class="label-sm">Email</th>
|
|
<th class="label-sm">Organisation</th>
|
|
<th class="label-sm">Role</th>
|
|
<th class="label-sm">Last seen</th>
|
|
<th class="label-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr class:list={[{ self: u.id === user.id }]}>
|
|
<td class="body-sm">{u.name}</td>
|
|
<td class="body-sm">{u.email}</td>
|
|
<td class="body-sm">{u.organisation}</td>
|
|
<td>
|
|
{u.id !== user.id ? (
|
|
<form method="POST" class="inline-form role-form">
|
|
<input type="hidden" name="action" value="change_role" />
|
|
<input type="hidden" name="user_id" value={u.id} />
|
|
<select name="role" class="select-inline label-sm" onchange="this.form.submit()">
|
|
<option value="pilot" selected={u.role === 'pilot'}>Pilot</option>
|
|
<option value="cab" selected={u.role === 'cab'}>CAB</option>
|
|
<option value="fenja" selected={u.role === 'fenja'}>Fenja</option>
|
|
</select>
|
|
</form>
|
|
) : (
|
|
<span class="body-sm" style="text-transform:capitalize">{u.role}</span>
|
|
)}
|
|
</td>
|
|
<td class="body-sm muted">
|
|
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
|
|
</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" />
|
|
<input type="hidden" name="user_id" value={u.id} />
|
|
<button type="submit" class="danger-btn label-sm"
|
|
onclick="return confirm('Deactivate this user?')">
|
|
Deactivate
|
|
</button>
|
|
</form>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<!-- Join requests tab -->
|
|
{tab === 'join' && (
|
|
<div class="tab-content">
|
|
<section class="section">
|
|
<h2 class="label-sm section-heading">Join requests</h2>
|
|
<p class="body-sm section-note">
|
|
Users who clicked "I want to join" on the home page. Use this to prioritise
|
|
follow-up and generate invite links.
|
|
</p>
|
|
{joinRequests.length === 0 ? (
|
|
<p class="body-sm empty-msg">No join requests yet.</p>
|
|
) : (
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="label-sm">Name</th>
|
|
<th class="label-sm">Email</th>
|
|
<th class="label-sm">Organisation</th>
|
|
<th class="label-sm">Requested</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{joinRequests.map((jr) => (
|
|
<tr>
|
|
<td class="body-sm">{jr.user_name}</td>
|
|
<td class="body-sm">{jr.user_email}</td>
|
|
<td class="body-sm">{jr.user_organisation}</td>
|
|
<td class="body-sm muted">{fmtDate(jr.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
</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} />
|
|
)}
|
|
|
|
{tab === 'dispatches' && (
|
|
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
|
)}
|
|
|
|
</div>
|
|
</AppLayout>
|
|
|
|
<style>
|
|
.page {
|
|
padding: var(--space-12) var(--space-20) var(--space-16);
|
|
max-width: var(--content-max);
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* ── Header ──────────────────────────────────────────────────────── */
|
|
.page-header {
|
|
max-width: 44rem;
|
|
margin-bottom: var(--space-8);
|
|
}
|
|
|
|
.eyebrow {
|
|
letter-spacing: var(--tracking-wider);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
|
|
.page-title { margin: 0; }
|
|
|
|
/* ── Tabs ────────────────────────────────────────────────────────── */
|
|
.tabs {
|
|
display: flex;
|
|
gap: var(--space-1);
|
|
margin-bottom: var(--space-8);
|
|
border-bottom: var(--ghost-border);
|
|
padding-bottom: var(--space-2);
|
|
}
|
|
|
|
.tab {
|
|
padding: var(--space-2) var(--space-4);
|
|
border-radius: var(--radius-sm);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
text-decoration: none;
|
|
border-bottom: none;
|
|
transition: color var(--duration-fast) var(--ease-standard),
|
|
background var(--duration-fast) var(--ease-standard);
|
|
}
|
|
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
|
|
.tab.active { color: var(--on-surface); background: var(--surface-container); }
|
|
|
|
.tab-count {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--secondary);
|
|
color: var(--on-secondary);
|
|
border-radius: var(--radius-full);
|
|
font-size: var(--text-label-sm);
|
|
font-weight: 700;
|
|
min-width: 1.25rem;
|
|
height: 1.25rem;
|
|
padding: 0 var(--space-1);
|
|
margin-left: var(--space-2);
|
|
}
|
|
|
|
.section-note {
|
|
color: var(--on-surface-muted);
|
|
margin: 0;
|
|
max-width: var(--reading-max);
|
|
}
|
|
|
|
/* ── Messages ────────────────────────────────────────────────────── */
|
|
.action-msg {
|
|
padding: var(--space-3) var(--space-4);
|
|
background: rgba(109, 140, 124, 0.1);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--pigment-copper);
|
|
margin-bottom: var(--space-6);
|
|
}
|
|
|
|
.form-error {
|
|
padding: var(--space-3) var(--space-4);
|
|
background: rgba(185, 107, 88, 0.08);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--pigment-terracotta);
|
|
margin-bottom: var(--space-4);
|
|
}
|
|
|
|
/* ── Tab content ─────────────────────────────────────────────────── */
|
|
.tab-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-12);
|
|
}
|
|
|
|
/* ── Section ─────────────────────────────────────────────────────── */
|
|
.section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-5);
|
|
}
|
|
|
|
.section-heading {
|
|
letter-spacing: var(--tracking-wider);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
}
|
|
|
|
.empty-msg {
|
|
color: var(--on-surface-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
/* ── Invite result ───────────────────────────────────────────────── */
|
|
.invite-result {
|
|
background: rgba(109, 140, 124, 0.08);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--space-5);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.invite-result-label {
|
|
color: var(--pigment-copper);
|
|
letter-spacing: var(--tracking-wide);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.invite-link-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.invite-link {
|
|
font-family: var(--font-mono);
|
|
background: var(--background);
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--on-surface);
|
|
word-break: break-all;
|
|
flex: 1;
|
|
}
|
|
|
|
.copy-btn {
|
|
padding: var(--space-2) var(--space-3);
|
|
background: var(--secondary);
|
|
color: var(--on-secondary);
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--font-sans);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Invite form ─────────────────────────────────────────────────── */
|
|
.invite-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-5);
|
|
max-width: 48rem;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.field-label {
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-variant);
|
|
}
|
|
|
|
.input,
|
|
.select {
|
|
width: 100%;
|
|
padding: var(--space-3) var(--space-4);
|
|
background: var(--surface-container-lowest);
|
|
border: var(--ghost-border);
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-body-md);
|
|
color: var(--on-surface);
|
|
outline: none;
|
|
transition: border-color var(--duration-fast) var(--ease-standard),
|
|
box-shadow var(--duration-fast) var(--ease-standard);
|
|
box-sizing: border-box;
|
|
}
|
|
.input:focus,
|
|
.select:focus {
|
|
border-color: var(--secondary);
|
|
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
|
|
}
|
|
|
|
.btn-primary {
|
|
align-self: flex-start;
|
|
padding: var(--space-2) var(--space-6);
|
|
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
|
|
color: var(--on-secondary);
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
font-family: var(--font-sans);
|
|
font-weight: 600;
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
}
|
|
.btn-primary:hover { opacity: 0.9; }
|
|
|
|
/* ── Data table ──────────────────────────────────────────────────── */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.data-table th {
|
|
text-align: left;
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
padding: var(--space-2) var(--space-3) var(--space-2) 0;
|
|
border-bottom: var(--ghost-border);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.data-table td {
|
|
padding: var(--space-3) var(--space-3) var(--space-3) 0;
|
|
border-bottom: var(--ghost-border);
|
|
color: var(--on-surface-variant);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.data-table tr.self td {
|
|
color: var(--on-surface-muted);
|
|
}
|
|
|
|
.muted { color: var(--on-surface-muted) !important; }
|
|
|
|
/* ── Inline elements ─────────────────────────────────────────────── */
|
|
.inline-form { display: inline; }
|
|
|
|
.select-inline {
|
|
background: none;
|
|
border: var(--ghost-border);
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-label-md);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-variant);
|
|
padding: 0.2em var(--space-3);
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
|
|
.danger-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family: var(--font-sans);
|
|
font-size: var(--text-label-md);
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
color: var(--pigment-terracotta);
|
|
padding: 0.2em var(--space-2);
|
|
border-radius: var(--radius-sm);
|
|
transition: background var(--duration-fast) var(--ease-standard);
|
|
}
|
|
.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>
|