feat(admin): Dispatches tab + user-edit form + extended event form
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>
This commit is contained in:
parent
1bf1993040
commit
fd3f433933
4 changed files with 399 additions and 28 deletions
175
src/components/admin/DispatchesTab.astro
Normal file
175
src/components/admin/DispatchesTab.astro
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
---
|
||||||
|
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
|
||||||
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
|
import { dispatchKindLabel } from '../../lib/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dispatches: DispatchWithAuthor[];
|
||||||
|
editing: DispatchWithAuthor | null;
|
||||||
|
fenjaUsers: UserPublic[];
|
||||||
|
currentUserId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
draft: 'Draft',
|
||||||
|
published: 'Published',
|
||||||
|
archived: 'Archived',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
|
||||||
|
const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
|
---
|
||||||
|
<div class="tab-content">
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="invite-form" novalidate>
|
||||||
|
<input type="hidden" name="action" value={formAction} />
|
||||||
|
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-title" class="label-sm field-label">Title</label>
|
||||||
|
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-kind" class="label-sm field-label">Kind</label>
|
||||||
|
<select id="d-kind" name="kind" class="select body-md" required>
|
||||||
|
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
|
||||||
|
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
|
||||||
|
<select id="d-author" name="author_id" class="select body-md" required>
|
||||||
|
{fenjaUsers.map(u => (
|
||||||
|
<option value={u.id} selected={u.id === defaultAuthorId}>
|
||||||
|
{u.name}{u.title ? ` — ${u.title}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
|
||||||
|
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
|
||||||
|
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-status" class="label-sm field-label">Status on save</label>
|
||||||
|
<select id="d-status" name="status" class="select body-md">
|
||||||
|
<option value="draft" selected>Draft (hidden from members)</option>
|
||||||
|
<option value="published">Published (stamps published_at)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
|
||||||
|
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="label-sm section-heading">All dispatches</h2>
|
||||||
|
{dispatches.length === 0 ? (
|
||||||
|
<p class="body-sm empty-msg">No dispatches yet.</p>
|
||||||
|
) : (
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="label-sm">Title</th>
|
||||||
|
<th class="label-sm">Kind</th>
|
||||||
|
<th class="label-sm">Author</th>
|
||||||
|
<th class="label-sm">Status</th>
|
||||||
|
<th class="label-sm">Published</th>
|
||||||
|
<th class="label-sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dispatches.map(d => (
|
||||||
|
<tr>
|
||||||
|
<td class="body-sm">{d.title}</td>
|
||||||
|
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
|
||||||
|
<td class="body-sm">{d.author_name}</td>
|
||||||
|
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
|
||||||
|
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
|
||||||
|
{d.status === 'draft' && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="publish_dispatch" />
|
||||||
|
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||||
|
<button type="submit" class="action-link label-sm">Publish</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{d.status === 'published' && (
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="archive_dispatch" />
|
||||||
|
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||||
|
<button type="submit" class="action-link label-sm">Archive</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<form method="POST" class="inline-form">
|
||||||
|
<input type="hidden" name="action" value="delete_dispatch" />
|
||||||
|
<input type="hidden" name="dispatch_id" value={d.id} />
|
||||||
|
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
||||||
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
|
||||||
|
.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-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
|
||||||
|
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
@ -13,9 +13,10 @@ const { events, editing, viewing, viewingRsvps } = Astro.props;
|
||||||
|
|
||||||
const KIND_LABEL = {
|
const KIND_LABEL = {
|
||||||
dinner: 'Dinner',
|
dinner: 'Dinner',
|
||||||
office_hours: 'Office hours',
|
office_hours: 'Studio hours',
|
||||||
summit: 'Summit',
|
summit: 'Summit',
|
||||||
virtual: 'Virtual',
|
virtual: 'Virtual',
|
||||||
|
working_session: 'Working session',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function toInputValue(sql: string | null | undefined): string {
|
function toInputValue(sql: string | null | undefined): string {
|
||||||
|
|
@ -59,7 +60,8 @@ const formAction = editing ? 'update_event' : 'create_event';
|
||||||
<label for="kind" class="label-sm field-label">Kind</label>
|
<label for="kind" class="label-sm field-label">Kind</label>
|
||||||
<select id="kind" name="kind" class="select body-md" required>
|
<select id="kind" name="kind" class="select body-md" required>
|
||||||
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
|
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
|
||||||
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Office hours</option>
|
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
|
||||||
|
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
|
||||||
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
|
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
|
||||||
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
|
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -84,6 +86,22 @@ const formAction = editing ? 'update_event' : 'create_event';
|
||||||
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
|
<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 ?? ''} />
|
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
|
||||||
|
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="duration_label" class="label-sm field-label">Duration label</label>
|
||||||
|
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
|
||||||
|
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
|
||||||
|
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
|
||||||
90
src/components/admin/UserEditTab.astro
Normal file
90
src/components/admin/UserEditTab.astro
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
---
|
||||||
|
import type { UserPublic } from '../../lib/db';
|
||||||
|
import { readFocusTags } from '../../lib/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: UserPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { member } = Astro.props;
|
||||||
|
const tagsStr = readFocusTags(member.focus_tags).join(', ');
|
||||||
|
---
|
||||||
|
<div class="tab-content">
|
||||||
|
<section class="section">
|
||||||
|
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
|
||||||
|
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="invite-form" novalidate>
|
||||||
|
<input type="hidden" name="action" value="update_user_admin" />
|
||||||
|
<input type="hidden" name="user_id" value={member.id} />
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label-sm field-label">Name</label>
|
||||||
|
<input type="text" class="input body-md" value={member.name} disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label-sm field-label">Email</label>
|
||||||
|
<input type="text" class="input body-md" value={member.email} disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label-sm field-label">Organisation</label>
|
||||||
|
<input type="text" class="input body-md" value={member.organisation} disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
|
||||||
|
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="title" class="label-sm field-label">Job title</label>
|
||||||
|
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
|
||||||
|
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
|
||||||
|
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
|
||||||
|
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary label-sm">Save changes</button>
|
||||||
|
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="body-sm note">
|
||||||
|
Role transitions and deactivation live in the participants table.
|
||||||
|
A member-number is allocated the first time a user becomes CAB and is never reused.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Tiny live counter for the 200-char pull-quote field — no framework.
|
||||||
|
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
|
||||||
|
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
|
||||||
|
if (!counter) return;
|
||||||
|
const update = () => { counter.textContent = `${el.value.length} / 200`; };
|
||||||
|
el.addEventListener('input', update);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
|
||||||
|
.note {
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
max-width: var(--reading-max);
|
||||||
|
}
|
||||||
|
.input:disabled {
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,24 +2,29 @@
|
||||||
import AppLayout from '../../layouts/AppLayout.astro';
|
import AppLayout from '../../layouts/AppLayout.astro';
|
||||||
import {
|
import {
|
||||||
getAllInvites, getAllUsersPublic, revokeInvite,
|
getAllInvites, getAllUsersPublic, revokeInvite,
|
||||||
createInvite, updateUserRole, deactivateUser,
|
createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
|
||||||
getAllJoinRequests,
|
getUserPublicById, getAllJoinRequests,
|
||||||
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
|
createPulse, updatePulse, publishPulse, closePulse, deletePulse,
|
||||||
getAllPulses, getPulseById, getPulseWithCounts,
|
getAllPulses, getPulseById, getPulseWithCounts,
|
||||||
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
|
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
|
||||||
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
|
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
|
||||||
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
|
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
|
||||||
getEventRsvpCount, getEventById,
|
getEventRsvpCount, getEventById,
|
||||||
|
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
|
||||||
|
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
|
||||||
recordActivity, getAllActivityForAdmin,
|
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 { parseFocusTags } from '../../lib/format';
|
||||||
import { notifyPulseOpened } from '../../lib/notify';
|
import { notifyPulseOpened } from '../../lib/notify';
|
||||||
import PulsesTab from '../../components/admin/PulsesTab.astro';
|
import PulsesTab from '../../components/admin/PulsesTab.astro';
|
||||||
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
|
import RoadmapTab from '../../components/admin/RoadmapTab.astro';
|
||||||
import EventsTab from '../../components/admin/EventsTab.astro';
|
import EventsTab from '../../components/admin/EventsTab.astro';
|
||||||
import ActivityTab from '../../components/admin/ActivityTab.astro';
|
import ActivityTab from '../../components/admin/ActivityTab.astro';
|
||||||
import type { Role, RoadmapStatus, EventKind } from '../../lib/db';
|
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;
|
const user = Astro.locals.user;
|
||||||
|
|
||||||
|
|
@ -75,6 +80,50 @@ if (Astro.request.method === 'POST') {
|
||||||
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');
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────────────────────────
|
// ── Pulses ───────────────────────────────────────────────────
|
||||||
} else if (action === 'create_pulse' || action === 'update_pulse') {
|
} else if (action === 'create_pulse' || action === 'update_pulse') {
|
||||||
const question = String(data.get('question') ?? '').trim();
|
const question = String(data.get('question') ?? '').trim();
|
||||||
|
|
@ -229,6 +278,12 @@ const joinRequests = getAllJoinRequests();
|
||||||
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
|
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
|
||||||
const viewId = Number(Astro.url.searchParams.get('view') ?? 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
|
// Per-tab data
|
||||||
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
||||||
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
|
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
|
||||||
|
|
@ -251,6 +306,7 @@ const MSGS: Record<string, string> = {
|
||||||
revoked: 'Invite revoked.',
|
revoked: 'Invite revoked.',
|
||||||
updated: 'Role updated.',
|
updated: 'Role updated.',
|
||||||
deactivated: 'User deactivated.',
|
deactivated: 'User deactivated.',
|
||||||
|
user_updated: 'Member profile updated.',
|
||||||
pulse_created: 'Pulse saved.',
|
pulse_created: 'Pulse saved.',
|
||||||
pulse_updated: 'Pulse updated.',
|
pulse_updated: 'Pulse updated.',
|
||||||
pulse_published: 'Pulse published — members notified.',
|
pulse_published: 'Pulse published — members notified.',
|
||||||
|
|
@ -263,6 +319,11 @@ const MSGS: Record<string, string> = {
|
||||||
event_created: 'Event saved.',
|
event_created: 'Event saved.',
|
||||||
event_updated: 'Event updated.',
|
event_updated: 'Event updated.',
|
||||||
event_deleted: 'Event deleted.',
|
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');
|
actionMsg = Astro.url.searchParams.get('msg');
|
||||||
---
|
---
|
||||||
|
|
@ -279,6 +340,7 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
|
<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=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=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=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=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
|
||||||
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
|
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
|
||||||
|
|
@ -397,7 +459,11 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Participants tab -->
|
<!-- Participants tab -->
|
||||||
{tab === 'participants' && (
|
{tab === 'participants' && editingUser && (
|
||||||
|
<UserEditTab member={editingUser} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'participants' && !editingUser && (
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="label-sm section-heading">All participants</h2>
|
<h2 class="label-sm section-heading">All participants</h2>
|
||||||
|
|
@ -436,7 +502,8 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
<td class="body-sm muted">
|
<td class="body-sm muted">
|
||||||
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
|
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="action-cell">
|
||||||
|
<a href={`/admin?tab=participants&edit=${u.id}`} class="action-link label-sm">Edit</a>
|
||||||
{u.id !== user.id && (
|
{u.id !== user.id && (
|
||||||
<form method="POST" class="inline-form">
|
<form method="POST" class="inline-form">
|
||||||
<input type="hidden" name="action" value="deactivate_user" />
|
<input type="hidden" name="action" value="deactivate_user" />
|
||||||
|
|
@ -509,6 +576,10 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
<ActivityTab rows={activityRows} />
|
<ActivityTab rows={activityRows} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'dispatches' && (
|
||||||
|
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
|
@ -791,4 +862,21 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
.danger-btn:hover {
|
.danger-btn:hover {
|
||||||
background: rgba(185, 107, 88, 0.08);
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue