feat(admin): retire old admin, add resource verifier, redirect /admin
The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.
- tests/admin-resources.test.ts: vitest-based verifier that walks
every registered resource and asserts:
- identity fields (key/label/plural/singular/groupKey)
- list.queryFn, action.handler, ops.* members are functions
- column kinds are in the registered set (text/pill/relative-date/
number/tag-list); same for columnsByFilter overrides
- field kinds are in the registered set (11 kinds)
- embed.component is in the registered set (pulse-sub-form)
- resource keys are unique, action keys are unique per resource
- at most one filter is isDefault
- groupKey resolves to a real group
- review-mode resources have at least one action
- ops.create requires a non-null form
87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
UserEditTab — all replaced by the resource configs).
Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
18d371b368
commit
8bbf8568f4
9 changed files with 180 additions and 1873 deletions
|
|
@ -1,44 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
---
|
||||
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db';
|
||||
import { fmtDateTime } from '../../lib/markdown';
|
||||
import { dispatchKindLabel } from '../../lib/format';
|
||||
|
||||
interface Props {
|
||||
dispatches: DispatchWithAuthor[];
|
||||
editing: DispatchWithAuthor | null;
|
||||
editingPoll: PulseRow | null;
|
||||
fenjaUsers: UserPublic[];
|
||||
currentUserId: number;
|
||||
}
|
||||
|
||||
const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props;
|
||||
|
||||
function toInputValue(sql: string | null | undefined): string {
|
||||
if (!sql) return '';
|
||||
return sql.replace(' ', 'T').slice(0, 16);
|
||||
}
|
||||
|
||||
const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : [];
|
||||
while (pollOptionsForForm.length < 4) pollOptionsForForm.push('');
|
||||
|
||||
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)</label>
|
||||
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea>
|
||||
<span class="body-sm muted">Write 2–4 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty.</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<!-- ── Attached poll (optional) ────────────────────────────── -->
|
||||
<fieldset class="poll-fieldset">
|
||||
<legend class="label-sm field-label">Attach a poll (optional)</legend>
|
||||
<input type="hidden" name="poll_explicit" value="1" />
|
||||
|
||||
<p class="body-sm muted poll-help">
|
||||
Fill in a question and at least two options to attach a poll. Leave them all blank
|
||||
to {editingPoll ? 'detach the existing poll' : 'skip'}.
|
||||
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
|
||||
<input
|
||||
type="text"
|
||||
id="d-poll-question"
|
||||
name="poll_question"
|
||||
class="input body-md"
|
||||
value={editingPoll?.question ?? ''}
|
||||
placeholder={editing ? editing.title : 'A question for the council'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="poll-options-grid">
|
||||
{pollOptionsForForm.map((val, i) => (
|
||||
<input
|
||||
type="text"
|
||||
name={`poll_option_${i}`}
|
||||
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
|
||||
class="input body-md"
|
||||
value={val}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="d-poll-opens"
|
||||
name="poll_opens_at"
|
||||
class="input body-md"
|
||||
value={toInputValue(editingPoll?.opens_at)}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="d-poll-closes"
|
||||
name="poll_closes_at"
|
||||
class="input body-md"
|
||||
value={toInputValue(editingPoll?.closes_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<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; }
|
||||
|
||||
.poll-fieldset {
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.poll-fieldset legend {
|
||||
padding: 0 var(--space-2);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.poll-help { color: var(--on-surface-muted); margin: 0; }
|
||||
.poll-existing-flag { color: var(--pigment-terracotta); }
|
||||
.poll-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.muted { color: var(--on-surface-muted); }
|
||||
|
||||
.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>
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
---
|
||||
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: 'Studio hours',
|
||||
summit: 'Summit',
|
||||
virtual: 'Virtual',
|
||||
working_session: 'Working session',
|
||||
} 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'}>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="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 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 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>
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
---
|
||||
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',
|
||||
in_beta: 'In beta',
|
||||
exploring: 'Exploring',
|
||||
considering: 'Considering',
|
||||
} 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' | 'in_beta' | 'exploring' | 'considering';
|
||||
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
|
||||
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
|
||||
in_beta: items.filter(i => i.status === 'in_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),
|
||||
considering: items.filter(i => i.status === 'considering').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="considering" selected={editing?.status === 'considering'}>Considering</option>
|
||||
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
|
||||
<option value="in_beta" selected={editing?.status === 'in_beta'}>In 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>
|
||||
|
||||
<div class="field">
|
||||
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="metadata_text"
|
||||
name="metadata_text"
|
||||
class="input body-md"
|
||||
value={editing?.metadata_text ?? ''}
|
||||
placeholder="e.g. Open question on key custody · Council input wanted"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
|
||||
</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','in_beta','exploring','considering'] 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; }
|
||||
|
||||
.muted { color: var(--on-surface-muted); }
|
||||
</style>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,607 +1,17 @@
|
|||
---
|
||||
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';
|
||||
import '../../admin/admin.css';
|
||||
/* ---------------------------------------------------------------------------
|
||||
* /admin — redirect to the first registered resource.
|
||||
*
|
||||
* Auth-gated like every other admin page. Members hitting /admin without
|
||||
* the fenja role land on /; admins land on the dispatches list view (the
|
||||
* default Backstage home).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { groups } from '../../admin/resources';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (user.role !== 'fenja') return Astro.redirect('/');
|
||||
|
||||
// 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;
|
||||
|
||||
// Parse optional poll attachment fields.
|
||||
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
|
||||
const pollQuestion = String(data.get('poll_question') ?? '').trim();
|
||||
const pollOpts = [0, 1, 2, 3]
|
||||
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
|
||||
.filter(s => s.length > 0);
|
||||
const pollOpens = String(data.get('poll_opens_at') ?? '');
|
||||
const pollCloses = String(data.get('poll_closes_at') ?? '');
|
||||
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
|
||||
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
|
||||
pollInput = {
|
||||
question: pollQuestion,
|
||||
options: pollOpts,
|
||||
opens_at: toSqlDate(pollOpens),
|
||||
closes_at: toSqlDate(pollCloses),
|
||||
};
|
||||
}
|
||||
|
||||
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, poll: pollInput });
|
||||
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,
|
||||
poll: pollInput, pollExplicit,
|
||||
});
|
||||
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 metadataText = String(data.get('metadata_text') ?? '').trim() || null;
|
||||
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
|
||||
|
||||
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
|
||||
formError = 'Title and status are required.';
|
||||
} else if (action === 'create_roadmap') {
|
||||
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText });
|
||||
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, metadata_text: metadataText,
|
||||
});
|
||||
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, metadata_text: item.metadata_text,
|
||||
});
|
||||
updateRoadmapItem(other.id, {
|
||||
title: other.title, description: other.description, status: other.status,
|
||||
target: other.target, display_order: item.display_order, metadata_text: other.metadata_text,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : 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');
|
||||
const first = groups.flatMap((g) => g.resources)[0];
|
||||
return Astro.redirect(first ? `/admin/${first.key}` : '/');
|
||||
---
|
||||
<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 (Pulses entity merged into Dispatches — polls now attach to articles) -->
|
||||
<div class="tabs">
|
||||
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</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=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} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* /admin/preview — temporary smoke route for the Backstage shell.
|
||||
*
|
||||
* Inline sample dispatches resource exercises the list view + edit panel
|
||||
* (every field kind is represented). Deleted in step 11 when the new admin
|
||||
* replaces the old.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import AdminLayout from '../../admin/components/AdminLayout.astro';
|
||||
import ResourceListView from '../../admin/components/ResourceListView.astro';
|
||||
import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro';
|
||||
import {
|
||||
getAllDispatchesForAdmin,
|
||||
getDispatchById,
|
||||
type DispatchWithAuthor,
|
||||
} from '../../lib/db';
|
||||
import type { Resource, ResourceGroup } from '../../admin/resource-types';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
if (user.role !== 'fenja') {
|
||||
return Astro.redirect('/');
|
||||
}
|
||||
|
||||
// Inline preview resource — exercises every field kind so the panel can
|
||||
// be eyeballed in isolation. Step 8 ships the production dispatches config.
|
||||
const dispatchesPreview: Resource = {
|
||||
key: 'dispatches',
|
||||
label: 'Dispatches',
|
||||
pluralLabel: 'Dispatches',
|
||||
singularLabel: 'Dispatch',
|
||||
groupKey: 'publishing',
|
||||
description: 'Updates, decisions, notes — the public record of pilot progress.',
|
||||
list: {
|
||||
queryFn: () => getAllDispatchesForAdmin() as unknown as Record<string, unknown>[],
|
||||
columns: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: String(item.title ?? ''),
|
||||
subtitle: `${item.author_name ?? 'Unknown'}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Kind',
|
||||
kind: 'pill',
|
||||
width: '140px',
|
||||
pillVariants: {
|
||||
decision: { label: 'Decision', class: 'pill-decision' },
|
||||
update: { label: 'Update', class: 'pill-update' },
|
||||
note: { label: 'Note', class: 'pill-note' },
|
||||
behind_the_scenes: { label: 'Behind the scenes', class: 'pill-bts' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
kind: 'pill',
|
||||
width: '110px',
|
||||
pillVariants: {
|
||||
draft: { label: 'Draft', class: 'pill-draft' },
|
||||
published: { label: 'Published', class: 'pill-published' },
|
||||
archived: { label: 'Archived', class: 'pill-archived' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
label: 'Updated',
|
||||
kind: 'relative-date',
|
||||
width: '110px',
|
||||
emptyFallback: '—',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||
{ key: 'published', label: 'Published', predicate: (i) => i.status === 'published' },
|
||||
{ key: 'drafts', label: 'Drafts', predicate: (i) => i.status === 'draft' },
|
||||
{ key: 'archived', label: 'Archived', predicate: (i) => i.status === 'archived' },
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by title or body…',
|
||||
fields: ['title', 'body'],
|
||||
},
|
||||
defaultSort: { key: 'updated_at', direction: 'desc' },
|
||||
pageSize: 10,
|
||||
},
|
||||
form: {
|
||||
fields: [
|
||||
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Kind',
|
||||
kind: 'select',
|
||||
options: [
|
||||
{ value: 'decision', label: 'Decision' },
|
||||
{ value: 'update', label: 'Update' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
{ value: 'behind_the_scenes', label: 'Behind the scenes' },
|
||||
],
|
||||
defaultValue: 'note',
|
||||
},
|
||||
{
|
||||
key: 'excerpt',
|
||||
label: 'Excerpt',
|
||||
kind: 'textarea',
|
||||
rows: 4,
|
||||
helperText:
|
||||
'Two to four sentences. First sentence becomes the lead paragraph on the dispatch banner; the rest follows in muted text.',
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
label: 'Body (markdown)',
|
||||
kind: 'markdown',
|
||||
rows: 14,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status on save',
|
||||
kind: 'select',
|
||||
options: [
|
||||
{ value: 'draft', label: 'Draft (hidden from members)' },
|
||||
{ value: 'published', label: 'Published (visible immediately)' },
|
||||
{ value: 'archived', label: 'Archived (hidden from members)' },
|
||||
],
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
],
|
||||
embeds: [
|
||||
{ key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' },
|
||||
],
|
||||
},
|
||||
ops: {
|
||||
getById: (id: number) =>
|
||||
getDispatchById(id) as unknown as Record<string, unknown> | null,
|
||||
delete: () => undefined, // surfaces the Delete button in the panel
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
key: 'publish',
|
||||
label: 'Publish now',
|
||||
visibleWhen: (item) => (item as DispatchWithAuthor).status === 'draft',
|
||||
confirmText: 'Publish this dispatch to all members?',
|
||||
handler: () => undefined,
|
||||
},
|
||||
{
|
||||
key: 'archive',
|
||||
label: 'Archive',
|
||||
visibleWhen: (item) => (item as DispatchWithAuthor).status === 'published',
|
||||
destructive: true,
|
||||
confirmText: 'Archive this dispatch? It will be hidden from members.',
|
||||
handler: () => undefined,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const previewGroups: ResourceGroup[] = [
|
||||
{ key: 'publishing', label: 'Publishing', resources: [dispatchesPreview] },
|
||||
{ key: 'council', label: 'The council', resources: [] },
|
||||
{ key: 'system', label: 'System', resources: [] },
|
||||
];
|
||||
|
||||
// Panel state from URL
|
||||
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
|
||||
const isNew = Astro.url.searchParams.get('new') === '1';
|
||||
const showPanel = isNew || editId !== null;
|
||||
|
||||
const editingItem =
|
||||
editId !== null
|
||||
? ((await dispatchesPreview.ops.getById?.(editId)) ?? null)
|
||||
: null;
|
||||
---
|
||||
|
||||
<AdminLayout title="Backstage preview" groups={previewGroups} activeResourceKey="dispatches">
|
||||
<ResourceListView resource={dispatchesPreview} groups={previewGroups} />
|
||||
{showPanel && (
|
||||
<ResourceEditPanel
|
||||
resource={dispatchesPreview}
|
||||
item={editingItem}
|
||||
actingUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
</AdminLayout>
|
||||
168
tests/admin-resources.test.ts
Normal file
168
tests/admin-resources.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Verifier for the Backstage admin resource registry.
|
||||
*
|
||||
* Walks every registered resource and asserts the invariants that keep the
|
||||
* shared components renderable. Compile-time TypeScript already catches most
|
||||
* shape issues via the strict Resource<T> generic — this suite covers what
|
||||
* TS can't see at the value level (function-ness of handlers, kind strings
|
||||
* actually being in the registered set, sentinel resource keys not colliding).
|
||||
*
|
||||
* Note on "every column.key is a valid field on the entity":
|
||||
* That's a structural assertion best enforced at compile time. Resource<T>
|
||||
* narrows the render/value callbacks to the entity's keys; this suite skips
|
||||
* trying to re-check it at runtime.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { groups } from '../src/admin/resources';
|
||||
import type {
|
||||
Field,
|
||||
Resource,
|
||||
ResourceGroup,
|
||||
Column,
|
||||
FormEmbed,
|
||||
} from '../src/admin/resource-types';
|
||||
|
||||
const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
|
||||
'text',
|
||||
'textarea',
|
||||
'markdown',
|
||||
'select',
|
||||
'select-async',
|
||||
'multi-select-async',
|
||||
'multi-text',
|
||||
'date',
|
||||
'datetime',
|
||||
'number',
|
||||
'readonly',
|
||||
]);
|
||||
|
||||
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
|
||||
'text', 'pill', 'relative-date', 'number', 'tag-list',
|
||||
]);
|
||||
|
||||
const KNOWN_EMBED_COMPONENTS: ReadonlySet<FormEmbed['component']> = new Set([
|
||||
'pulse-sub-form',
|
||||
]);
|
||||
|
||||
function allResources(): Resource[] {
|
||||
return groups.flatMap((g: ResourceGroup) => g.resources as Resource[]);
|
||||
}
|
||||
|
||||
describe('admin resource registry', () => {
|
||||
it('has at least one group with resources registered', () => {
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
expect(allResources().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every resource key is unique across the registry', () => {
|
||||
const keys = allResources().map((r) => r.key);
|
||||
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
|
||||
expect(dups).toEqual([]);
|
||||
});
|
||||
|
||||
it('every resource.groupKey points at a real group', () => {
|
||||
const groupKeys = new Set(groups.map((g) => g.key));
|
||||
for (const r of allResources()) {
|
||||
expect(groupKeys.has(r.groupKey), `${r.key} → unknown groupKey ${r.groupKey}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
describe.each(allResources())('resource: $key', (resource: Resource) => {
|
||||
it('has required identity fields', () => {
|
||||
expect(resource.key).toBeTruthy();
|
||||
expect(resource.label).toBeTruthy();
|
||||
expect(resource.pluralLabel).toBeTruthy();
|
||||
expect(resource.singularLabel).toBeTruthy();
|
||||
expect(resource.groupKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it('list.queryFn is a function', () => {
|
||||
expect(typeof resource.list.queryFn).toBe('function');
|
||||
});
|
||||
|
||||
it('every column has a registered kind (or none = text)', () => {
|
||||
for (const col of resource.list.columns as Column<unknown>[]) {
|
||||
const kind = col.kind ?? 'text';
|
||||
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: column ${col.key} → unknown kind ${kind}`).toBe(true);
|
||||
}
|
||||
if (resource.list.columnsByFilter) {
|
||||
for (const [filterKey, cols] of Object.entries(resource.list.columnsByFilter)) {
|
||||
for (const col of cols as Column<unknown>[]) {
|
||||
const kind = col.kind ?? 'text';
|
||||
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: columnsByFilter.${filterKey}.${col.key} → unknown kind ${kind}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exactly one or zero filters is isDefault', () => {
|
||||
const filters = resource.list.filters ?? [];
|
||||
const defaults = filters.filter((f) => f.isDefault);
|
||||
expect(defaults.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('every filter.predicate is a function', () => {
|
||||
for (const f of resource.list.filters ?? []) {
|
||||
expect(typeof f.predicate, `${resource.key}: filter ${f.key} predicate`).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('every form field has a registered kind', () => {
|
||||
for (const field of resource.form?.fields ?? []) {
|
||||
expect(
|
||||
KNOWN_FIELD_KINDS.has(field.kind),
|
||||
`${resource.key}: field ${field.key} → unknown kind ${field.kind}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every embed.component is in the registered set', () => {
|
||||
for (const embed of resource.form?.embeds ?? []) {
|
||||
expect(
|
||||
KNOWN_EMBED_COMPONENTS.has(embed.component),
|
||||
`${resource.key}: embed ${embed.key} → unknown component ${embed.component}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every ops member is a function (when defined)', () => {
|
||||
const ops = resource.ops;
|
||||
if (ops.create) expect(typeof ops.create).toBe('function');
|
||||
if (ops.update) expect(typeof ops.update).toBe('function');
|
||||
if (ops.delete) expect(typeof ops.delete).toBe('function');
|
||||
if (ops.getById) expect(typeof ops.getById).toBe('function');
|
||||
});
|
||||
|
||||
it('every action.handler is a function', () => {
|
||||
for (const action of resource.actions ?? []) {
|
||||
expect(typeof action.handler, `${resource.key}: action ${action.key}`).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('action keys are unique', () => {
|
||||
const keys = (resource.actions ?? []).map((a) => a.key);
|
||||
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
|
||||
expect(dups).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders SOMETHING when an item is clicked (form OR summary, or no clicks)', () => {
|
||||
// If form is null and there's no summary, the resource is non-clickable.
|
||||
// If form is null but a summary is defined → review panel renders.
|
||||
// If form is defined → edit panel renders.
|
||||
// The only invalid shape is form=null + summary defined + no actions,
|
||||
// which would render an empty review panel. Flag it.
|
||||
if (resource.form === null && resource.summary !== undefined) {
|
||||
const actions = resource.actions ?? [];
|
||||
expect(actions.length, `${resource.key}: review-mode resource with no actions`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('when ops.create is defined, the form is defined too', () => {
|
||||
// Can't render the create panel without a form.
|
||||
if (resource.ops.create) {
|
||||
expect(resource.form, `${resource.key}: ops.create is defined but form is null`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue