Compare commits
10 commits
65191256ec
...
8bbf8568f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bbf8568f4 | |||
| 18d371b368 | |||
| e9a986d484 | |||
| dd9ea68fab | |||
| 3aaa21e6af | |||
| 09a10061b2 | |||
| cc9332e6e2 | |||
| dd7215d828 | |||
| ea056fff7b | |||
| 103bfa2f0c |
39 changed files with 5112 additions and 1982 deletions
1417
src/admin/admin.css
Normal file
1417
src/admin/admin.css
Normal file
File diff suppressed because it is too large
Load diff
121
src/admin/components/AdminLayout.astro
Normal file
121
src/admin/components/AdminLayout.astro
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* AdminLayout — the two-pane Backstage shell.
|
||||
*
|
||||
* Top strip: wordmark + " / Backstage" on the left, "Back to the portal"
|
||||
* link on the right.
|
||||
* Left: grouped resource sidebar with active-state and count badges.
|
||||
* Right: slot for the current resource view (list or panel).
|
||||
*
|
||||
* Standalone from AppLayout deliberately — the member-facing portal and
|
||||
* the admin surface have different chrome.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import '../admin.css';
|
||||
import type { ResourceGroup, Resource } from '../resource-types';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** Which resource is currently active — used for sidebar highlighting. */
|
||||
activeResourceKey?: string;
|
||||
/** The registry to render in the sidebar. */
|
||||
groups: ResourceGroup[];
|
||||
}
|
||||
|
||||
const { title, activeResourceKey, groups } = Astro.props;
|
||||
|
||||
// Pre-compute list-counts and notify-counts for every registered resource,
|
||||
// so the sidebar can render badges without doing async work in markup.
|
||||
type SidebarEntry = {
|
||||
resource: Resource;
|
||||
count: number;
|
||||
notify: number;
|
||||
};
|
||||
|
||||
async function loadEntries(group: ResourceGroup): Promise<SidebarEntry[]> {
|
||||
return Promise.all(
|
||||
group.resources.map(async (resource): Promise<SidebarEntry> => {
|
||||
const items = await resource.list.queryFn();
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
resource,
|
||||
count: arr.length,
|
||||
notify: resource.notifyCount ? resource.notifyCount.count(arr) : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const groupedEntries = await Promise.all(
|
||||
groups.map(async (g) => ({ group: g, entries: await loadEntries(g) })),
|
||||
);
|
||||
|
||||
const hasAnyResources = groupedEntries.some((g) => g.entries.length > 0);
|
||||
---
|
||||
|
||||
<BaseLayout title={title}>
|
||||
<div class="backstage">
|
||||
|
||||
<!-- ── Top strip ──────────────────────────────────────────────── -->
|
||||
<header class="bs-topbar" role="banner">
|
||||
<a href="/admin" class="bs-brand" aria-label="Backstage — home">
|
||||
<img src="/logo.svg" alt="Fenja AI" class="bs-brand-mark" />
|
||||
<span class="bs-brand-sep" aria-hidden="true">·</span>
|
||||
<span class="bs-brand-project">Project <em class="bs-brand-bifrost">Bifrost</em></span>
|
||||
<span class="bs-brand-slash" aria-hidden="true">/</span>
|
||||
<span class="bs-brand-backstage">Backstage</span>
|
||||
</a>
|
||||
|
||||
<a href="/pulse" class="bs-back-link label-sm">← Back to the portal</a>
|
||||
</header>
|
||||
|
||||
<!-- ── Two-pane body ──────────────────────────────────────────── -->
|
||||
<div class="bs-body">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="bs-sidebar" aria-label="Resource navigation">
|
||||
{hasAnyResources ? (
|
||||
<ul class="bs-groups">
|
||||
{groupedEntries.map(({ group, entries }) => (
|
||||
entries.length > 0 && (
|
||||
<li class="bs-group">
|
||||
<p class="bs-group-label">{group.label}</p>
|
||||
<ul class="bs-resources">
|
||||
{entries.map(({ resource, count, notify }) => {
|
||||
const isActive = activeResourceKey === resource.key;
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={`/admin/${resource.key}`}
|
||||
class:list={['bs-resource', { active: isActive }]}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<span class="bs-resource-label">{resource.label}</span>
|
||||
{count > 0 && (
|
||||
<span class:list={['bs-count', { notify: notify > 0 }]}>
|
||||
{notify > 0 ? notify : count}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="bs-sidebar-empty">No resources registered yet.</p>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<!-- Main pane -->
|
||||
<main class="bs-main" role="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
57
src/admin/components/FieldRenderer.astro
Normal file
57
src/admin/components/FieldRenderer.astro
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* FieldRenderer — dispatches on field.kind to the right input component
|
||||
* and wraps it with label + helper + error.
|
||||
*
|
||||
* Branches must stay exhaustive; the `never` fallback flags any unhandled
|
||||
* Field kind at compile time.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import TextField from './fields/TextField.astro';
|
||||
import TextareaField from './fields/TextareaField.astro';
|
||||
import MarkdownField from './fields/MarkdownField.astro';
|
||||
import SelectField from './fields/SelectField.astro';
|
||||
import SelectAsyncField from './fields/SelectAsyncField.astro';
|
||||
import MultiSelectAsyncField from './fields/MultiSelectAsyncField.astro';
|
||||
import MultiTextField from './fields/MultiTextField.astro';
|
||||
import DateField from './fields/DateField.astro';
|
||||
import DatetimeField from './fields/DatetimeField.astro';
|
||||
import NumberField from './fields/NumberField.astro';
|
||||
import ReadonlyField from './fields/ReadonlyField.astro';
|
||||
import type { Field } from '../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: Field;
|
||||
value: unknown;
|
||||
error?: string;
|
||||
item: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const { field, value, error, item } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="bs-field" data-field={field.key}>
|
||||
<label class="bs-label" for={`f-${field.key}`}>
|
||||
{field.label}
|
||||
{field.required && <span class="bs-required" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
|
||||
{field.kind === 'text' && <TextField field={field} value={value} />}
|
||||
{field.kind === 'textarea' && <TextareaField field={field} value={value} />}
|
||||
{field.kind === 'markdown' && <MarkdownField field={field} value={value} />}
|
||||
{field.kind === 'select' && <SelectField field={field} value={value} />}
|
||||
{field.kind === 'select-async' && <SelectAsyncField field={field} value={value} />}
|
||||
{field.kind === 'multi-select-async' && <MultiSelectAsyncField field={field} value={value} />}
|
||||
{field.kind === 'multi-text' && <MultiTextField field={field} value={value} />}
|
||||
{field.kind === 'date' && <DateField field={field} value={value} />}
|
||||
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
|
||||
{field.kind === 'number' && <NumberField field={field} value={value} />}
|
||||
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
|
||||
|
||||
{field.helperText && (
|
||||
<p class="bs-helper">{field.helperText}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p class="bs-field-error" role="alert">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
112
src/admin/components/ListCell.astro
Normal file
112
src/admin/components/ListCell.astro
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Internal cell renderer for ResourceListView.
|
||||
*
|
||||
* One Column kind per branch. Branches must stay exhaustive over Column<T>
|
||||
* — if a new kind is added to resource-types.ts, TypeScript will warn here
|
||||
* via the `never` fallback.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { relativeTime } from '../../lib/format';
|
||||
import type { Column } from '../resource-types';
|
||||
|
||||
interface Props {
|
||||
column: Column<Record<string, unknown>>;
|
||||
item: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const { column, item } = Astro.props;
|
||||
const kind = column.kind ?? 'text';
|
||||
|
||||
// Text column — default rendering ─────────────────────────────────────────
|
||||
let textTitle: string | null = null;
|
||||
let textSubtitle: string | null = null;
|
||||
if (kind === 'text') {
|
||||
const col = column as Extract<typeof column, { kind?: 'text' }>;
|
||||
if (col.render) {
|
||||
const r = col.render(item);
|
||||
textTitle = r.title;
|
||||
textSubtitle = r.subtitle ?? null;
|
||||
} else {
|
||||
const v = item[column.key];
|
||||
textTitle = v == null ? '' : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Pill column ─────────────────────────────────────────────────────────────
|
||||
let pillLabel: string | null = null;
|
||||
let pillClass: string | null = null;
|
||||
if (kind === 'pill') {
|
||||
const col = column as Extract<typeof column, { kind: 'pill' }>;
|
||||
const raw = col.value ? col.value(item) : (item[column.key] as string | undefined);
|
||||
if (raw) {
|
||||
const variant = col.pillVariants[raw];
|
||||
if (variant) {
|
||||
pillLabel = variant.label;
|
||||
pillClass = variant.class;
|
||||
} else {
|
||||
pillLabel = raw;
|
||||
pillClass = 'pill-draft'; // graceful fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relative-date column ────────────────────────────────────────────────────
|
||||
let relText: string | null = null;
|
||||
let relEmpty: string | null = null;
|
||||
if (kind === 'relative-date') {
|
||||
const col = column as Extract<typeof column, { kind: 'relative-date' }>;
|
||||
const raw = col.value ? col.value(item) : (item[column.key] as string | null | undefined);
|
||||
if (raw) {
|
||||
relText = relativeTime(raw);
|
||||
} else {
|
||||
relEmpty = col.emptyFallback ?? '—';
|
||||
}
|
||||
}
|
||||
|
||||
// Number column ───────────────────────────────────────────────────────────
|
||||
let numberText: string | null = null;
|
||||
if (kind === 'number') {
|
||||
const col = column as Extract<typeof column, { kind: 'number' }>;
|
||||
const raw = col.value ? col.value(item) : (item[column.key] as number | null | undefined);
|
||||
numberText = raw == null ? '—' : String(raw);
|
||||
}
|
||||
|
||||
// Tag-list column ─────────────────────────────────────────────────────────
|
||||
let tags: string[] = [];
|
||||
if (kind === 'tag-list') {
|
||||
const col = column as Extract<typeof column, { kind: 'tag-list' }>;
|
||||
tags = col.value(item);
|
||||
}
|
||||
---
|
||||
|
||||
{kind === 'text' && (
|
||||
<div class="bs-cell-text">
|
||||
<span class:list={['bs-cell-title', { primary: column.primary }]}>{textTitle}</span>
|
||||
{textSubtitle && <span class="bs-cell-subtitle">{textSubtitle}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'pill' && (
|
||||
pillLabel
|
||||
? <span class:list={['bs-pill', pillClass]}>{pillLabel}</span>
|
||||
: <span class="bs-cell-empty">—</span>
|
||||
)}
|
||||
|
||||
{kind === 'relative-date' && (
|
||||
relText
|
||||
? <span class="bs-cell-rel">{relText}</span>
|
||||
: <span class="bs-cell-empty">{relEmpty}</span>
|
||||
)}
|
||||
|
||||
{kind === 'number' && (
|
||||
<span class="bs-cell-number">{numberText}</span>
|
||||
)}
|
||||
|
||||
{kind === 'tag-list' && (
|
||||
tags.length > 0
|
||||
? <ul class="bs-cell-tags">
|
||||
{tags.map(t => <li class="bs-tag">{t}</li>)}
|
||||
</ul>
|
||||
: <span class="bs-cell-empty">—</span>
|
||||
)}
|
||||
307
src/admin/components/ResourceEditPanel.astro
Normal file
307
src/admin/components/ResourceEditPanel.astro
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* ResourceEditPanel — right-slide panel for create + edit.
|
||||
*
|
||||
* Rendered alongside ResourceListView when the URL carries ?edit=<id> or
|
||||
* ?new=1. POSTs back to the same URL; the route handler in step 7 reads
|
||||
* _action (save | delete | <action.key>) and dispatches.
|
||||
*
|
||||
* Visible-when predicates are evaluated server-side against current values
|
||||
* (existing item or form defaults). Live-toggle while editing is Phase 2.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import FieldRenderer from './FieldRenderer.astro';
|
||||
import PulseSubForm from '../embeds/PulseSubForm.astro';
|
||||
import type { Field, FieldContext, Resource } from '../resource-types';
|
||||
|
||||
interface Props {
|
||||
resource: Resource;
|
||||
/** The item being edited, or null when creating. */
|
||||
item: Record<string, unknown> | null;
|
||||
/** Pre-validated form values from a failed prior submission (re-fill). */
|
||||
formValues?: Record<string, unknown>;
|
||||
errors?: Record<string, string>;
|
||||
actingUserId: number;
|
||||
}
|
||||
|
||||
const { resource, item, formValues, errors = {}, actingUserId } = Astro.props;
|
||||
|
||||
const isCreate = item === null;
|
||||
// Review mode = the panel is showing an existing item AND either the resource
|
||||
// has no form, OR it has a summary that should be preferred over the form
|
||||
// (e.g. invitations are write-once: form is for create, summary is for edit).
|
||||
const isReviewMode =
|
||||
!isCreate &&
|
||||
(resource.form === null || resource.summary !== undefined);
|
||||
const singular = resource.singularLabel.toLowerCase();
|
||||
const title = isReviewMode
|
||||
? `Review ${singular}`
|
||||
: isCreate
|
||||
? `New ${singular}`
|
||||
: `Edit ${singular}`;
|
||||
|
||||
// In review mode, the resource MUST have a summary function — that's what
|
||||
// fills the panel body when the form is suppressed.
|
||||
if (isReviewMode && !resource.summary) {
|
||||
throw new Error(
|
||||
`ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`,
|
||||
);
|
||||
}
|
||||
|
||||
// And the create path needs a form.
|
||||
if (isCreate && !resource.form) {
|
||||
throw new Error(
|
||||
`ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Initial form values: prior failed submission > existing item > defaults
|
||||
const seedValues: Record<string, unknown> = { ...(item ?? {}), ...(formValues ?? {}) };
|
||||
|
||||
const ctx: FieldContext = {
|
||||
formValues: seedValues,
|
||||
item,
|
||||
actingUserId,
|
||||
};
|
||||
|
||||
function resolveDefault(field: Field): unknown {
|
||||
if (field.defaultValue === undefined) return undefined;
|
||||
if (typeof field.defaultValue === 'function') {
|
||||
return (field.defaultValue as (c: FieldContext) => unknown)(ctx);
|
||||
}
|
||||
return field.defaultValue;
|
||||
}
|
||||
|
||||
function valueFor(field: Field): unknown {
|
||||
if (field.key in seedValues) return seedValues[field.key];
|
||||
return resolveDefault(field);
|
||||
}
|
||||
|
||||
const visibleFields = resource.form
|
||||
? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx))
|
||||
: [];
|
||||
|
||||
const embeds = resource.form?.embeds ?? [];
|
||||
|
||||
// Review-mode summary
|
||||
const summaryEntries = isReviewMode && item
|
||||
? resource.summary!(item)
|
||||
: [];
|
||||
|
||||
// One-shot invite link surfaced after create/action — read from URL
|
||||
const inviteUrl = Astro.url.searchParams.get('invite_url');
|
||||
|
||||
// Build the close URL — drop edit/new but keep filter/q/page
|
||||
const closeUrl = (() => {
|
||||
const next = new URLSearchParams(Astro.url.searchParams);
|
||||
next.delete('edit');
|
||||
next.delete('new');
|
||||
const s = next.toString();
|
||||
return s ? `${Astro.url.pathname}?${s}` : Astro.url.pathname;
|
||||
})();
|
||||
|
||||
// Actions visible for this item — only when editing an existing item
|
||||
const actions = isCreate
|
||||
? []
|
||||
: (resource.actions ?? []).filter(
|
||||
(a) => !a.visibleWhen || a.visibleWhen(item!),
|
||||
);
|
||||
|
||||
// Form action URL: keep the panel-state params so a re-render after a
|
||||
// validation failure stays on the same item.
|
||||
const formAction = Astro.url.pathname + Astro.url.search;
|
||||
---
|
||||
|
||||
<div class="bs-panel-scrim">
|
||||
<a href={closeUrl} class="bs-panel-scrim-link" aria-label="Close panel"></a>
|
||||
|
||||
<aside class="bs-panel" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header class="bs-panel-head">
|
||||
<h2 class="bs-panel-title">{title}</h2>
|
||||
<a href={closeUrl} class="bs-panel-close" aria-label="Close">×</a>
|
||||
</header>
|
||||
|
||||
<form method="POST" action={formAction} class="bs-panel-form" id="bs-panel-form">
|
||||
<div class="bs-panel-body">
|
||||
{inviteUrl && (
|
||||
<section class="bs-invite-result" data-invite-block>
|
||||
<p class="bs-invite-result-label">Magic link — copy and send now. It will not be shown again.</p>
|
||||
<div class="bs-invite-link-row">
|
||||
<code class="bs-invite-link" id="bs-invite-link">{inviteUrl}</code>
|
||||
<button type="button" class="bs-copy-btn" data-copy-target="#bs-invite-link">Copy</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isReviewMode ? (
|
||||
<dl class="bs-summary">
|
||||
{summaryEntries.map((entry) => (
|
||||
<div class="bs-summary-row">
|
||||
<dt class="bs-summary-label">{entry.label}</dt>
|
||||
<dd class="bs-summary-value">{entry.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<>
|
||||
{visibleFields.map((field) => (
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
value={valueFor(field)}
|
||||
error={errors[field.key]}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
|
||||
{embeds.length > 0 && embeds.map((embed) => {
|
||||
const show = !embed.visibleWhen || embed.visibleWhen(ctx);
|
||||
if (!show) return null;
|
||||
return (
|
||||
<section class="bs-embed" data-embed={embed.key}>
|
||||
<h3 class="bs-embed-title">{embed.title}</h3>
|
||||
{embed.component === 'pulse-sub-form' && (
|
||||
<PulseSubForm item={item} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer class="bs-panel-foot">
|
||||
<div class="bs-panel-foot-left">
|
||||
{!isReviewMode && (
|
||||
<button type="submit" name="_action" value="save" class="bs-panel-save">
|
||||
{isCreate ? `Create ${singular}` : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value={a.key}
|
||||
class:list={['bs-panel-secondary', { destructive: a.destructive }]}
|
||||
data-confirm={a.confirmText ?? null}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isCreate && !isReviewMode && resource.ops.delete && (
|
||||
<button
|
||||
type="submit"
|
||||
name="_action"
|
||||
value="delete"
|
||||
class="bs-panel-delete"
|
||||
data-confirm={`Delete this ${singular}? This cannot be undone.`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Confirm-before-submit for buttons with data-confirm ──────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest('button[data-confirm]');
|
||||
if (!(btn instanceof HTMLButtonElement)) return;
|
||||
const text = btn.getAttribute('data-confirm');
|
||||
if (text && !window.confirm(text)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Escape to close ──────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const closer = document.querySelector('.bs-panel-close') as HTMLAnchorElement | null;
|
||||
if (closer) closer.click();
|
||||
});
|
||||
|
||||
// ── Markdown Write/Preview toggle ────────────────────────────────────────
|
||||
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
|
||||
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
|
||||
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
|
||||
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
|
||||
if (!input || !preview) return;
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const mode = tab.getAttribute('data-md-mode');
|
||||
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
|
||||
if (mode === 'preview') {
|
||||
input.hidden = true;
|
||||
preview.hidden = false;
|
||||
} else {
|
||||
input.hidden = false;
|
||||
preview.hidden = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy-to-clipboard for invite-link blocks ─────────────────────────────
|
||||
document.querySelectorAll<HTMLButtonElement>('.bs-copy-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const sel = btn.getAttribute('data-copy-target');
|
||||
const target = sel ? document.querySelector(sel) : null;
|
||||
const text = target?.textContent ?? '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'Copied';
|
||||
setTimeout(() => { btn.textContent = orig; }, 1400);
|
||||
} catch {
|
||||
// clipboard blocked — leave the link visible for manual copy
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── MultiTextField add / remove ──────────────────────────────────────────
|
||||
document.querySelectorAll<HTMLElement>('.bs-multitext').forEach((root) => {
|
||||
const rows = root.querySelector<HTMLElement>('.bs-multitext-rows');
|
||||
const addBtn = root.querySelector<HTMLButtonElement>('.bs-multitext-add');
|
||||
if (!rows || !addBtn) return;
|
||||
const fieldKey = root.dataset.multitext ?? 'option';
|
||||
const min = Number(root.dataset.min ?? '1');
|
||||
const max = Number(root.dataset.max ?? '10');
|
||||
|
||||
function updateButtons() {
|
||||
const inputs = rows!.querySelectorAll<HTMLElement>('.bs-multitext-row');
|
||||
addBtn!.disabled = inputs.length >= max;
|
||||
inputs.forEach((row) => {
|
||||
const rm = row.querySelector<HTMLButtonElement>('.bs-multitext-remove');
|
||||
if (rm) rm.disabled = inputs.length <= min;
|
||||
});
|
||||
}
|
||||
|
||||
rows.addEventListener('click', (e) => {
|
||||
const rm = (e.target as HTMLElement | null)?.closest('.bs-multitext-remove');
|
||||
if (!rm) return;
|
||||
const row = rm.closest('.bs-multitext-row');
|
||||
if (row && rows.querySelectorAll('.bs-multitext-row').length > min) {
|
||||
row.remove();
|
||||
updateButtons();
|
||||
}
|
||||
});
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
const inputs = rows.querySelectorAll('.bs-multitext-row');
|
||||
if (inputs.length >= max) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'bs-multitext-row';
|
||||
row.innerHTML =
|
||||
`<input type="text" name="${fieldKey}" class="bs-input" value="" placeholder="Option ${inputs.length + 1}" />` +
|
||||
`<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>`;
|
||||
rows.appendChild(row);
|
||||
updateButtons();
|
||||
});
|
||||
|
||||
updateButtons();
|
||||
});
|
||||
</script>
|
||||
211
src/admin/components/ResourceListView.astro
Normal file
211
src/admin/components/ResourceListView.astro
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* ResourceListView — shared list rendering for every Backstage resource.
|
||||
*
|
||||
* Reads URL state (?filter, ?q, ?page) and derives:
|
||||
* - active filter (with isDefault fallback)
|
||||
* - active column set (columnsByFilter override → columns)
|
||||
* - filtered + searched + sorted + paginated row set
|
||||
*
|
||||
* Rows are full anchor elements pointing at ?edit=<id> so the table is
|
||||
* fully keyboard-navigable and works without JS. The panel that consumes
|
||||
* the edit param ships in step 6.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import ListCell from './ListCell.astro';
|
||||
import type { Column, Resource, ResourceGroup } from '../resource-types';
|
||||
|
||||
interface Props {
|
||||
resource: Resource;
|
||||
groups: ResourceGroup[];
|
||||
}
|
||||
|
||||
const { resource, groups } = Astro.props;
|
||||
const url = Astro.url;
|
||||
|
||||
// ── Resolve state from URL ────────────────────────────────────────────────
|
||||
const filters = resource.list.filters ?? [];
|
||||
const defaultFilterKey =
|
||||
filters.find((f) => f.isDefault)?.key ?? filters[0]?.key ?? 'all';
|
||||
const filterKey = url.searchParams.get('filter') ?? defaultFilterKey;
|
||||
const activeFilter = filters.find((f) => f.key === filterKey);
|
||||
|
||||
const search = (url.searchParams.get('q') ?? '').trim();
|
||||
const pageParam = Number(url.searchParams.get('page') ?? '1');
|
||||
const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
|
||||
const pageSize = resource.list.pageSize ?? 25;
|
||||
|
||||
// ── Load + transform ──────────────────────────────────────────────────────
|
||||
const queried = await resource.list.queryFn();
|
||||
const allItems = (Array.isArray(queried) ? queried : []) as Record<string, unknown>[];
|
||||
|
||||
const filtered = activeFilter
|
||||
? allItems.filter((item) => activeFilter.predicate(item))
|
||||
: allItems;
|
||||
|
||||
const searched = search && resource.list.search
|
||||
? filtered.filter((item) => {
|
||||
const q = search.toLowerCase();
|
||||
return resource.list.search!.fields.some((field) => {
|
||||
const v = item[field as string];
|
||||
return typeof v === 'string' && v.toLowerCase().includes(q);
|
||||
});
|
||||
})
|
||||
: filtered;
|
||||
|
||||
const sort = resource.list.defaultSort;
|
||||
const sorted = sort
|
||||
? [...searched].sort((a, b) => {
|
||||
const av = a[sort.key];
|
||||
const bv = b[sort.key];
|
||||
if (av === bv) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = av < bv ? -1 : 1;
|
||||
return sort.direction === 'desc' ? -cmp : cmp;
|
||||
})
|
||||
: searched;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const page = Math.min(requestedPage, totalPages);
|
||||
const start = (page - 1) * pageSize;
|
||||
const pageItems = sorted.slice(start, start + pageSize);
|
||||
|
||||
// ── Resolve column set (columnsByFilter override → columns) ───────────────
|
||||
const columns: Column<Record<string, unknown>>[] =
|
||||
(resource.list.columnsByFilter && resource.list.columnsByFilter[filterKey]) ??
|
||||
resource.list.columns;
|
||||
|
||||
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
|
||||
|
||||
// ── Group eyebrow ─────────────────────────────────────────────────────────
|
||||
const group = groups.find((g) => g.key === resource.groupKey);
|
||||
|
||||
// ── Helper: build a query string preserving the other params ──────────────
|
||||
function withParams(overrides: Record<string, string | number | null>): string {
|
||||
const next = new URLSearchParams(url.searchParams);
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v == null) next.delete(k);
|
||||
else next.set(k, String(v));
|
||||
}
|
||||
const s = next.toString();
|
||||
return s ? `${url.pathname}?${s}` : url.pathname;
|
||||
}
|
||||
|
||||
const showNewButton = resource.form !== null && resource.ops.create !== undefined;
|
||||
const hasItems = allItems.length > 0;
|
||||
const hasMatches = pageItems.length > 0;
|
||||
// A row is only clickable when the panel has something to render — either an
|
||||
// editable form or a review summary. Pure debug resources (activity) skip the
|
||||
// anchor wrapper so clicks don't dirty the URL with a ?edit= that goes nowhere.
|
||||
const rowsClickable = resource.form !== null || resource.summary !== undefined;
|
||||
---
|
||||
|
||||
<section class="bs-list">
|
||||
<!-- ── Page header ─────────────────────────────────────────────── -->
|
||||
<header class="bs-list-header">
|
||||
<div class="bs-list-heading">
|
||||
{group && <p class="bs-list-eyebrow">{group.label}</p>}
|
||||
<h1 class="bs-list-title">{resource.pluralLabel}</h1>
|
||||
{resource.description && <p class="bs-list-desc">{resource.description}</p>}
|
||||
</div>
|
||||
{showNewButton && (
|
||||
<a href={withParams({ new: '1', edit: null })} class="bs-list-new label-sm">
|
||||
+ New {resource.singularLabel.toLowerCase()}
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<!-- ── Toolbar: search + filter chips ──────────────────────────── -->
|
||||
{(resource.list.search || filters.length > 0) && (
|
||||
<div class="bs-toolbar">
|
||||
{resource.list.search && (
|
||||
<form method="get" action={url.pathname} class="bs-search-form" role="search">
|
||||
{/* Preserve the active filter when submitting a search */}
|
||||
{filterKey !== defaultFilterKey && (
|
||||
<input type="hidden" name="filter" value={filterKey} />
|
||||
)}
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
class="bs-search-input"
|
||||
placeholder={resource.list.search.placeholder}
|
||||
value={search}
|
||||
aria-label={resource.list.search.placeholder}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{filters.length > 0 && (
|
||||
<div class="bs-filters" role="tablist" aria-label="Filter">
|
||||
{filters.map((f) => {
|
||||
const isActive = f.key === filterKey;
|
||||
const href = withParams({ filter: f.key === defaultFilterKey ? null : f.key, page: null });
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
class:list={['bs-chip', { active: isActive }]}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{f.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- ── Grid table ──────────────────────────────────────────────── -->
|
||||
{hasMatches ? (
|
||||
<div class="bs-grid" role="table" style={`--bs-grid-cols: ${gridTemplate}`}>
|
||||
<div class="bs-grid-head" role="row">
|
||||
{columns.map((col) => (
|
||||
<div class="bs-grid-th" role="columnheader">{col.label}</div>
|
||||
))}
|
||||
</div>
|
||||
{pageItems.map((item) => {
|
||||
const id = Number(item.id);
|
||||
const Tag = rowsClickable ? 'a' : 'div';
|
||||
const linkProps = rowsClickable
|
||||
? {
|
||||
href: withParams({ edit: id, new: null }),
|
||||
'aria-label': `Open ${resource.singularLabel.toLowerCase()} ${id}`,
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<Tag class="bs-grid-row" role="row" {...linkProps}>
|
||||
{columns.map((col) => (
|
||||
<div class="bs-grid-td" role="cell">
|
||||
<ListCell column={col} item={item} />
|
||||
</div>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p class="bs-list-empty">
|
||||
{hasItems
|
||||
? 'No items match the current filters.'
|
||||
: `No ${resource.pluralLabel.toLowerCase()} yet.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<!-- ── Pagination (only when paged) ────────────────────────────── -->
|
||||
{totalPages > 1 && (
|
||||
<nav class="bs-pagination" aria-label="Pagination">
|
||||
{page > 1 ? (
|
||||
<a class="bs-page-link" href={withParams({ page: page - 1 })}>← Previous</a>
|
||||
) : (
|
||||
<span class="bs-page-link disabled" aria-hidden="true">← Previous</span>
|
||||
)}
|
||||
<span class="bs-page-status">Page {page} of {totalPages}</span>
|
||||
{page < totalPages ? (
|
||||
<a class="bs-page-link" href={withParams({ page: page + 1 })}>Next →</a>
|
||||
) : (
|
||||
<span class="bs-page-link disabled" aria-hidden="true">Next →</span>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
29
src/admin/components/fields/DateField.astro
Normal file
29
src/admin/components/fields/DateField.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
import type { DateField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: DateField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
|
||||
// Coerce ISO datetime → "YYYY-MM-DD" for the <input type="date"> control.
|
||||
function toDateInputValue(v: unknown): string {
|
||||
if (v == null || v === '') return '';
|
||||
const s = String(v);
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
const v = toDateInputValue(value);
|
||||
---
|
||||
|
||||
<input
|
||||
type="date"
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input"
|
||||
value={v}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
/>
|
||||
30
src/admin/components/fields/DatetimeField.astro
Normal file
30
src/admin/components/fields/DatetimeField.astro
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
import type { DatetimeField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: DatetimeField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
|
||||
// Coerce ISO datetime (which may be "YYYY-MM-DD HH:MM:SS" SQLite-style or
|
||||
// "YYYY-MM-DDTHH:mm:ss[Z|+offset]") to the "YYYY-MM-DDTHH:mm" the input wants.
|
||||
function toDatetimeLocal(v: unknown): string {
|
||||
if (v == null || v === '') return '';
|
||||
const s = String(v).replace(' ', 'T');
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
const v = toDatetimeLocal(value);
|
||||
---
|
||||
|
||||
<input
|
||||
type="datetime-local"
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input"
|
||||
value={v}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
/>
|
||||
41
src/admin/components/fields/MarkdownField.astro
Normal file
41
src/admin/components/fields/MarkdownField.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* MarkdownField — textarea with a Preview toggle.
|
||||
*
|
||||
* The preview panel is rendered server-side once with the current value, so
|
||||
* it's available the moment the toggle flips even without a network call.
|
||||
* Toggling on does NOT re-render — that's a Phase 2 enhancement. The toggle
|
||||
* itself is keyboard-accessible.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { renderMd } from '../../../lib/markdown';
|
||||
import type { MarkdownField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: MarkdownField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const v = value == null ? '' : String(value);
|
||||
const previewHtml = v ? renderMd(v) : '<p class="bs-md-empty">Nothing to preview yet.</p>';
|
||||
---
|
||||
|
||||
<div class="bs-md" data-md-field={field.key}>
|
||||
<div class="bs-md-toolbar">
|
||||
<button type="button" class="bs-md-tab is-active" data-md-mode="edit">Write</button>
|
||||
<button type="button" class="bs-md-tab" data-md-mode="preview">Preview</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input bs-textarea bs-md-input"
|
||||
rows={field.rows ?? 14}
|
||||
maxlength={field.maxLength}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
>{v}</textarea>
|
||||
|
||||
<div class="bs-md-preview" hidden set:html={previewHtml} />
|
||||
</div>
|
||||
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal file
47
src/admin/components/fields/MultiSelectAsyncField.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* MultiSelectAsyncField — checkbox grid for picking multiple options.
|
||||
*
|
||||
* Submits as repeated form values under field.key[] (browser default for
|
||||
* multiple checkboxes with the same name). The route handler in step 7
|
||||
* normalises the array form via getAll().
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import type { MultiSelectAsyncField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: MultiSelectAsyncField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const options = await field.loadOptions();
|
||||
|
||||
const selected = new Set<string>();
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) selected.add(String(v));
|
||||
}
|
||||
---
|
||||
|
||||
<fieldset class="bs-multiselect" disabled={field.readOnly}>
|
||||
<legend class="bs-visually-hidden">{field.label}</legend>
|
||||
{options.length === 0 && (
|
||||
<p class="bs-multiselect-empty">No options available.</p>
|
||||
)}
|
||||
{options.map(opt => {
|
||||
const id = `f-${field.key}-${opt.value}`;
|
||||
const isChecked = selected.has(String(opt.value));
|
||||
return (
|
||||
<label class="bs-multiselect-row" for={id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
name={field.key}
|
||||
value={String(opt.value)}
|
||||
checked={isChecked}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
53
src/admin/components/fields/MultiTextField.astro
Normal file
53
src/admin/components/fields/MultiTextField.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* MultiTextField — N text inputs with add/remove, used for pulse options.
|
||||
*
|
||||
* Submits as repeated form values under field.key. Initial input count is
|
||||
* Math.max(minItems, value.length). Add/remove buttons are managed by a
|
||||
* small client script attached to the panel.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import type { MultiTextField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: MultiTextField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const min = field.minItems ?? 1;
|
||||
const max = field.maxItems ?? 10;
|
||||
|
||||
const initialValues: string[] = Array.isArray(value)
|
||||
? value.map((v) => (v == null ? '' : String(v)))
|
||||
: [];
|
||||
const initialCount = Math.max(min, initialValues.length, 1);
|
||||
while (initialValues.length < initialCount) initialValues.push('');
|
||||
---
|
||||
|
||||
<div
|
||||
class="bs-multitext"
|
||||
data-multitext={field.key}
|
||||
data-min={min}
|
||||
data-max={max}
|
||||
>
|
||||
<div class="bs-multitext-rows">
|
||||
{initialValues.map((v, i) => (
|
||||
<div class="bs-multitext-row">
|
||||
<input
|
||||
type="text"
|
||||
name={field.key}
|
||||
class="bs-input"
|
||||
value={v}
|
||||
placeholder={field.placeholderEach ?? `Option ${i + 1}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="bs-multitext-remove"
|
||||
aria-label="Remove option"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" class="bs-multitext-add">+ Add option</button>
|
||||
</div>
|
||||
24
src/admin/components/fields/NumberField.astro
Normal file
24
src/admin/components/fields/NumberField.astro
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
import type { NumberField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: NumberField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const v = value == null ? '' : String(value);
|
||||
---
|
||||
|
||||
<input
|
||||
type="number"
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input"
|
||||
value={v}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
/>
|
||||
14
src/admin/components/fields/ReadonlyField.astro
Normal file
14
src/admin/components/fields/ReadonlyField.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import type { ReadonlyField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: ReadonlyField;
|
||||
value: unknown;
|
||||
item: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const { field, value, item } = Astro.props;
|
||||
const display = field.render ? field.render(value, item) : (value == null ? '—' : String(value));
|
||||
---
|
||||
|
||||
<div class="bs-readonly" id={`f-${field.key}`}>{display}</div>
|
||||
25
src/admin/components/fields/SelectAsyncField.astro
Normal file
25
src/admin/components/fields/SelectAsyncField.astro
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
import type { SelectAsyncField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: SelectAsyncField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const current = value == null ? '' : String(value);
|
||||
const options = await field.loadOptions();
|
||||
---
|
||||
|
||||
<select
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input bs-select"
|
||||
required={field.required}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
{!field.required && <option value="">—</option>}
|
||||
{options.map(opt => (
|
||||
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
24
src/admin/components/fields/SelectField.astro
Normal file
24
src/admin/components/fields/SelectField.astro
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
import type { SelectField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: SelectField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const current = value == null ? '' : String(value);
|
||||
---
|
||||
|
||||
<select
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input bs-select"
|
||||
required={field.required}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
{!field.required && <option value="">—</option>}
|
||||
{field.options.map(opt => (
|
||||
<option value={String(opt.value)} selected={String(opt.value) === current}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
23
src/admin/components/fields/TextField.astro
Normal file
23
src/admin/components/fields/TextField.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
import type { TextField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: TextField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const v = value == null ? '' : String(value);
|
||||
---
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input"
|
||||
value={v}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
maxlength={field.maxLength}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
/>
|
||||
22
src/admin/components/fields/TextareaField.astro
Normal file
22
src/admin/components/fields/TextareaField.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
import type { TextareaField } from '../../resource-types';
|
||||
|
||||
interface Props {
|
||||
field: TextareaField;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const { field, value } = Astro.props;
|
||||
const v = value == null ? '' : String(value);
|
||||
---
|
||||
|
||||
<textarea
|
||||
id={`f-${field.key}`}
|
||||
name={field.key}
|
||||
class="bs-input bs-textarea"
|
||||
rows={field.rows ?? 4}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
maxlength={field.maxLength}
|
||||
required={field.required}
|
||||
readonly={field.readOnly}
|
||||
>{v}</textarea>
|
||||
101
src/admin/embeds/PulseSubForm.astro
Normal file
101
src/admin/embeds/PulseSubForm.astro
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* PulseSubForm — embedded fieldset inside the dispatch edit panel.
|
||||
*
|
||||
* Reads the dispatch's current pulse (if any) and renders editable fields.
|
||||
* Submitted via the parent dispatch form with `pulse_*` prefixed names; the
|
||||
* dispatches resource's ops.create/update read these out of ctx.formData.
|
||||
*
|
||||
* If pulse_question is blank on save, no pulse is attached. The status field
|
||||
* intentionally isn't here — pulses follow their parent dispatch's lifecycle
|
||||
* (draft → open on publish, → closed on archive).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import { getPulseById } from '../../lib/db';
|
||||
|
||||
interface Props {
|
||||
/** The dispatch being edited, or null when creating. */
|
||||
item: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const { item } = Astro.props;
|
||||
const pulseId = item?.pulse_id ? Number(item.pulse_id) : null;
|
||||
const pulse = pulseId ? getPulseById(pulseId) : null;
|
||||
|
||||
const question = pulse?.question ?? '';
|
||||
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
|
||||
while (initialOptions.length < 2) initialOptions.push('');
|
||||
|
||||
function toDatetimeLocal(v: string | null | undefined): string {
|
||||
if (!v) return '';
|
||||
const s = String(v).replace(' ', 'T');
|
||||
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
---
|
||||
|
||||
<div class="bs-pulse-embed">
|
||||
<p class="bs-helper">
|
||||
Attach a pulse by filling in the question and at least two options.
|
||||
Leaving the question blank means no pulse on this dispatch.
|
||||
Pulses open when the dispatch is published and close when it's archived.
|
||||
</p>
|
||||
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_question">Question</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pulse_question"
|
||||
name="pulse_question"
|
||||
class="bs-input"
|
||||
value={question}
|
||||
placeholder="What should we prioritise next?"
|
||||
maxlength="240"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bs-field">
|
||||
<label class="bs-label">Options</label>
|
||||
<div class="bs-multitext" data-multitext="pulse_options" data-min="2" data-max="4">
|
||||
<div class="bs-multitext-rows">
|
||||
{initialOptions.map((opt, i) => (
|
||||
<div class="bs-multitext-row">
|
||||
<input
|
||||
type="text"
|
||||
name="pulse_options"
|
||||
class="bs-input"
|
||||
value={opt}
|
||||
placeholder={`Option ${i + 1}`}
|
||||
maxlength="120"
|
||||
/>
|
||||
<button type="button" class="bs-multitext-remove" aria-label="Remove option">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" class="bs-multitext-add">+ Add option</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-field-row">
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_opens_at">Opens</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="pulse_opens_at"
|
||||
name="pulse_opens_at"
|
||||
class="bs-input"
|
||||
value={toDatetimeLocal(pulse?.opens_at)}
|
||||
/>
|
||||
</div>
|
||||
<div class="bs-field">
|
||||
<label class="bs-label" for="pulse_closes_at">Closes</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="pulse_closes_at"
|
||||
name="pulse_closes_at"
|
||||
class="bs-input"
|
||||
value={toDatetimeLocal(pulse?.closes_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
313
src/admin/resource-types.ts
Normal file
313
src/admin/resource-types.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Resource type definitions for the Backstage admin surface.
|
||||
*
|
||||
* Every admin-managed entity is declared as a single Resource<T> object.
|
||||
* The shared components (AdminLayout, ResourceListView, ResourceEditPanel)
|
||||
* consume these objects and never know about specific entities.
|
||||
*
|
||||
* Adding a new entity = write a Resource config + register it. That is the
|
||||
* load-bearing invariant of the rebuild.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// ── Option (used for select / select-async / multi-select-async) ────────────
|
||||
export interface Option<V = string | number> {
|
||||
value: V;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ── Pill variants (status/kind columns + pill display elsewhere) ────────────
|
||||
export interface PillVariant {
|
||||
label: string;
|
||||
/** CSS class defined in src/admin/admin.css — e.g. 'pill-published'. */
|
||||
class: string;
|
||||
}
|
||||
export type PillVariants = Record<string, PillVariant>;
|
||||
|
||||
// ── Field context — passed to visibleWhen / defaultValue resolvers ──────────
|
||||
export interface FieldContext {
|
||||
/** Current form values keyed by field.key. */
|
||||
formValues: Record<string, unknown>;
|
||||
/** The item being edited, or null on create. */
|
||||
item: Record<string, unknown> | null;
|
||||
/** Acting admin's user id, available for current-user defaults. */
|
||||
actingUserId: number;
|
||||
}
|
||||
|
||||
// ── Fields ──────────────────────────────────────────────────────────────────
|
||||
interface FieldBase {
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
helperText?: string;
|
||||
/** Default for create. Can be a literal or a resolver receiving FieldContext. */
|
||||
defaultValue?: unknown | ((ctx: FieldContext) => unknown);
|
||||
/** Hide the field entirely when this returns false. Re-evaluated on every render. */
|
||||
visibleWhen?: (ctx: FieldContext) => boolean;
|
||||
/** Display the field but disable editing. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface TextField extends FieldBase {
|
||||
kind: 'text';
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
placeholder?: string;
|
||||
}
|
||||
export interface TextareaField extends FieldBase {
|
||||
kind: 'textarea';
|
||||
rows?: number;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
export interface MarkdownField extends FieldBase {
|
||||
kind: 'markdown';
|
||||
rows?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
export interface SelectField extends FieldBase {
|
||||
kind: 'select';
|
||||
options: Option[];
|
||||
}
|
||||
export interface SelectAsyncField extends FieldBase {
|
||||
kind: 'select-async';
|
||||
loadOptions: () => Promise<Option[]> | Option[];
|
||||
}
|
||||
export interface MultiSelectAsyncField extends FieldBase {
|
||||
kind: 'multi-select-async';
|
||||
loadOptions: () => Promise<Option[]> | Option[];
|
||||
}
|
||||
/** Series of free-text inputs — used for pulse options (2–4 entries). */
|
||||
export interface MultiTextField extends FieldBase {
|
||||
kind: 'multi-text';
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
placeholderEach?: string;
|
||||
}
|
||||
export interface DateField extends FieldBase {
|
||||
kind: 'date';
|
||||
}
|
||||
export interface DatetimeField extends FieldBase {
|
||||
kind: 'datetime';
|
||||
}
|
||||
export interface NumberField extends FieldBase {
|
||||
kind: 'number';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
/** Display-only — never edited; renders the value verbatim or via render(). */
|
||||
export interface ReadonlyField extends FieldBase {
|
||||
kind: 'readonly';
|
||||
render?: (value: unknown, item: Record<string, unknown> | null) => string;
|
||||
}
|
||||
|
||||
export type Field =
|
||||
| TextField
|
||||
| TextareaField
|
||||
| MarkdownField
|
||||
| SelectField
|
||||
| SelectAsyncField
|
||||
| MultiSelectAsyncField
|
||||
| MultiTextField
|
||||
| DateField
|
||||
| DatetimeField
|
||||
| NumberField
|
||||
| ReadonlyField;
|
||||
|
||||
// ── Columns ─────────────────────────────────────────────────────────────────
|
||||
interface ColumnBase {
|
||||
key: string;
|
||||
label: string;
|
||||
/** CSS grid-template-columns track (e.g. '1.7fr', '120px'). */
|
||||
width?: string;
|
||||
/** Primary column gets the larger title styling. At most one per columns array. */
|
||||
primary?: boolean;
|
||||
/** When set, the list can be sorted by this column. */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface TextColumn<T> extends ColumnBase {
|
||||
kind?: 'text';
|
||||
/** Override the default <td>{item[key]}</td> rendering. */
|
||||
render?: (item: T) => { title: string; subtitle?: string };
|
||||
}
|
||||
export interface PillColumn<T> extends ColumnBase {
|
||||
kind: 'pill';
|
||||
pillVariants: PillVariants;
|
||||
/** Override which value to look up in pillVariants (default = item[key]). */
|
||||
value?: (item: T) => string;
|
||||
}
|
||||
export interface RelativeDateColumn<T> extends ColumnBase {
|
||||
kind: 'relative-date';
|
||||
/** Shown when the value is null/undefined. */
|
||||
emptyFallback?: string;
|
||||
value?: (item: T) => string | null | undefined;
|
||||
}
|
||||
export interface NumberColumn<T> extends ColumnBase {
|
||||
kind: 'number';
|
||||
value?: (item: T) => number | null | undefined;
|
||||
}
|
||||
/** Compact list of pills — for focus_tags, audience, etc. */
|
||||
export interface TagListColumn<T> extends ColumnBase {
|
||||
kind: 'tag-list';
|
||||
value: (item: T) => string[];
|
||||
}
|
||||
|
||||
export type Column<T> =
|
||||
| TextColumn<T>
|
||||
| PillColumn<T>
|
||||
| RelativeDateColumn<T>
|
||||
| NumberColumn<T>
|
||||
| TagListColumn<T>;
|
||||
|
||||
// ── Filters ─────────────────────────────────────────────────────────────────
|
||||
export interface Filter<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
predicate: (item: T) => boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
export interface SearchConfig<T> {
|
||||
placeholder: string;
|
||||
/** Object keys to search; coerced to string and matched case-insensitively. */
|
||||
fields: (keyof T & string)[];
|
||||
}
|
||||
|
||||
// ── Sort ────────────────────────────────────────────────────────────────────
|
||||
export interface SortConfig {
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ── List view config ────────────────────────────────────────────────────────
|
||||
export interface ListConfig<T> {
|
||||
queryFn: () => T[] | Promise<T[]>;
|
||||
/** Default column set. */
|
||||
columns: Column<T>[];
|
||||
/**
|
||||
* Override columns when a specific filter is active. The key matches a
|
||||
* Filter.key. Used by the Users resource (council vs pilots) to swap
|
||||
* member_number/focus_tags for role/last_seen_at.
|
||||
*/
|
||||
columnsByFilter?: Record<string, Column<T>[]>;
|
||||
filters?: Filter<T>[];
|
||||
search?: SearchConfig<T>;
|
||||
defaultSort?: SortConfig;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// ── Embedded sub-forms (the Pulse fieldset inside dispatch edit) ────────────
|
||||
export interface FormEmbed {
|
||||
/** Unique key inside the parent form. */
|
||||
key: string;
|
||||
title: string;
|
||||
/**
|
||||
* Discriminator the panel uses to pick a renderer component.
|
||||
* Keep this small — new embed kinds are explicit additions, not generic.
|
||||
*/
|
||||
component: 'pulse-sub-form';
|
||||
visibleWhen?: (ctx: FieldContext) => boolean;
|
||||
}
|
||||
|
||||
// ── Form config ─────────────────────────────────────────────────────────────
|
||||
export interface FormConfig {
|
||||
fields: Field[];
|
||||
/** Optional embedded sub-form sections (e.g. pulse inside dispatch). */
|
||||
embeds?: FormEmbed[];
|
||||
}
|
||||
|
||||
// ── Op context — passed to CRUD ops and actions ─────────────────────────────
|
||||
export interface OpContext {
|
||||
user: { id: number; role: string };
|
||||
/**
|
||||
* Raw POST FormData — opt-in escape hatch for resources whose form has
|
||||
* embedded sub-forms (e.g. the pulse fieldset inside dispatches). Most
|
||||
* resources ignore this and work off the typed `data` argument.
|
||||
*/
|
||||
formData?: FormData;
|
||||
/**
|
||||
* Set by an op or action to surface a one-shot result on the next render
|
||||
* (e.g. the magic link after an invite is created). The route handler
|
||||
* reads this after the op returns and propagates it via the redirect URL.
|
||||
*/
|
||||
result?: ActionResult;
|
||||
}
|
||||
|
||||
// ── CRUD operations ─────────────────────────────────────────────────────────
|
||||
export interface ResourceOps<T> {
|
||||
/** Returns the new item's id. */
|
||||
create?: (data: Record<string, unknown>, ctx: OpContext) => number | Promise<number>;
|
||||
update?: (id: number, data: Record<string, unknown>, ctx: OpContext) => void | Promise<void>;
|
||||
delete?: (id: number, ctx: OpContext) => void | Promise<void>;
|
||||
getById?: (id: number) => T | null | Promise<T | null>;
|
||||
}
|
||||
|
||||
// ── Action results — surfaced inside the edit panel ─────────────────────────
|
||||
/** No additional UI — just close the panel with a toast. */
|
||||
export interface ActionResultToast {
|
||||
kind: 'toast';
|
||||
}
|
||||
/** Render the generated invite link in the panel with a Copy button. */
|
||||
export interface ActionResultInviteLink {
|
||||
kind: 'invite-link';
|
||||
url: string;
|
||||
}
|
||||
export type ActionResult = ActionResultToast | ActionResultInviteLink;
|
||||
|
||||
// ── Actions (publish, archive, approve, etc.) ───────────────────────────────
|
||||
export interface ResourceAction<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Hide the action when this returns false for the current item. */
|
||||
visibleWhen?: (item: T) => boolean;
|
||||
/** Confirm dialog text. If omitted, no confirmation is shown. */
|
||||
confirmText?: string;
|
||||
/** Destructive actions render in terracotta. */
|
||||
destructive?: boolean;
|
||||
handler: (id: number, ctx: OpContext) => ActionResult | void | Promise<ActionResult | void>;
|
||||
}
|
||||
|
||||
// ── Notification count (sidebar badge in terracotta if > 0) ─────────────────
|
||||
export interface NotifyCount<T> {
|
||||
/** Return the count of items needing attention (pending requests, stale drafts, etc.). */
|
||||
count: (items: T[]) => number;
|
||||
}
|
||||
|
||||
// ── Resource ────────────────────────────────────────────────────────────────
|
||||
export interface Resource<T = Record<string, unknown>> {
|
||||
/** URL slug — /admin/<key>. */
|
||||
key: string;
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
singularLabel: string;
|
||||
/** Matches a ResourceGroup.key in the registry. */
|
||||
groupKey: string;
|
||||
/** Optional one-line subtitle under the page title. */
|
||||
description?: string;
|
||||
/** Returns the member-facing URL for an item (for the "View on portal" link). */
|
||||
publicRoutePattern?: (item: T) => string | null;
|
||||
list: ListConfig<T>;
|
||||
/** null marks the resource as read-only (no edit panel, no "+ New" button). */
|
||||
form: FormConfig | null;
|
||||
/**
|
||||
* When form is null but the resource still has actions (e.g. join_requests),
|
||||
* this defines the read-only fields the review panel renders above the
|
||||
* action buttons. Returns label/value pairs in display order.
|
||||
*/
|
||||
summary?: (item: T) => { label: string; value: string }[];
|
||||
ops: ResourceOps<T>;
|
||||
actions?: ResourceAction<T>[];
|
||||
notifyCount?: NotifyCount<T>;
|
||||
}
|
||||
|
||||
// ── Resource groups (sidebar sections) ──────────────────────────────────────
|
||||
export interface ResourceGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
// Each resource carries its own item type. Erase the generic at the
|
||||
// registration boundary so different resources can coexist in one array.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resources: Resource<any>[];
|
||||
}
|
||||
102
src/admin/resources/activity.ts
Normal file
102
src/admin/resources/activity.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Activity resource — read-only debug feed.
|
||||
*
|
||||
* Activity rows are emitted by side effects elsewhere in the app (voting,
|
||||
* RSVPs, roadmap-ship transitions, pulse opens). The admin view is a tail
|
||||
* of the table for monitoring; no create, no edit, no delete.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
getAllActivityForAdmin,
|
||||
type ActivityKind,
|
||||
type ActivityRow,
|
||||
} from '../../lib/db';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
const KIND_LABEL: Record<ActivityKind, string> = {
|
||||
voted: 'Voted',
|
||||
rsvped: 'RSVPed',
|
||||
booked_office_hours: 'Booked office hours',
|
||||
roadmap_shipped: 'Roadmap shipped',
|
||||
pulse_opened: 'Pulse opened',
|
||||
};
|
||||
|
||||
const KIND_PILL_CLASS: Record<ActivityKind, string> = {
|
||||
voted: 'pill-update',
|
||||
rsvped: 'pill-published',
|
||||
booked_office_hours: 'pill-bts',
|
||||
roadmap_shipped: 'pill-shipping',
|
||||
pulse_opened: 'pill-pending',
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const activityResource: Resource<ActivityRow> = {
|
||||
key: 'activity',
|
||||
label: 'Activity',
|
||||
pluralLabel: 'Activity',
|
||||
singularLabel: 'Event',
|
||||
groupKey: 'system',
|
||||
description: 'Recent member actions: votes, RSVPs, office-hour bookings, pulse opens, roadmap ships.',
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllActivityForAdmin(200),
|
||||
columns: [
|
||||
{
|
||||
key: 'actor_name',
|
||||
label: 'Actor',
|
||||
primary: true,
|
||||
width: '1.5fr',
|
||||
render: (item) => ({
|
||||
title: item.actor_name,
|
||||
subtitle: item.actor_role,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Kind',
|
||||
kind: 'pill',
|
||||
width: '160px',
|
||||
pillVariants: Object.fromEntries(
|
||||
(Object.keys(KIND_LABEL) as ActivityKind[]).map((k) => [
|
||||
k,
|
||||
{ label: KIND_LABEL[k], class: KIND_PILL_CLASS[k] },
|
||||
]),
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'subject',
|
||||
label: 'Subject',
|
||||
width: '1fr',
|
||||
render: (item) => ({
|
||||
title: `${item.subject_type} #${item.subject_id}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'When',
|
||||
kind: 'relative-date',
|
||||
width: '120px',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||
{
|
||||
key: 'last_7_days',
|
||||
label: 'Last 7 days',
|
||||
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 7 * DAY_MS,
|
||||
},
|
||||
{
|
||||
key: 'last_30_days',
|
||||
label: 'Last 30 days',
|
||||
predicate: (i) => Date.now() - new Date(i.created_at).getTime() < 30 * DAY_MS,
|
||||
},
|
||||
],
|
||||
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||
pageSize: 100,
|
||||
},
|
||||
|
||||
// Pure read view — no form, no summary, no ops, no actions.
|
||||
form: null,
|
||||
ops: {},
|
||||
};
|
||||
260
src/admin/resources/dispatches.ts
Normal file
260
src/admin/resources/dispatches.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Dispatches resource — the canonical example of the resource pattern.
|
||||
*
|
||||
* The optional pulse sub-form is read out of ctx.formData by the create/update
|
||||
* handlers (pulse_question / pulse_options[] / pulse_opens_at / pulse_closes_at).
|
||||
* Status changes ride on the same Save submit: when the form's chosen status
|
||||
* differs from the current one, publishDispatch / archiveDispatch fire after
|
||||
* the content save.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
createDispatch,
|
||||
updateDispatch,
|
||||
publishDispatch,
|
||||
archiveDispatch,
|
||||
deleteDispatch,
|
||||
getDispatchById,
|
||||
getAllDispatchesForAdmin,
|
||||
getAllUsersPublic,
|
||||
type DispatchKind,
|
||||
type DispatchPollInput,
|
||||
type DispatchStatus,
|
||||
type DispatchWithAuthor,
|
||||
} from '../../lib/db';
|
||||
import { dispatchSlug } from '../../lib/format';
|
||||
import type { FieldContext, Resource } from '../resource-types';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
|
||||
function toSqliteDatetime(s: string): string {
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
||||
}
|
||||
|
||||
function nowSqlite(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function plusDaysSqlite(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
|
||||
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
|
||||
const question = String(formData.get('pulse_question') ?? '').trim();
|
||||
if (!question) return null;
|
||||
const options = formData
|
||||
.getAll('pulse_options')
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
if (options.length < 2) return null;
|
||||
const opens_at =
|
||||
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
|
||||
const closes_at =
|
||||
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
|
||||
return { question, options, opens_at, closes_at };
|
||||
}
|
||||
|
||||
// ── Resource ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const dispatchesResource: Resource<DispatchWithAuthor> = {
|
||||
key: 'dispatches',
|
||||
label: 'Dispatches',
|
||||
pluralLabel: 'Dispatches',
|
||||
singularLabel: 'Dispatch',
|
||||
groupKey: 'publishing',
|
||||
description: 'Updates, decisions, notes — the public record of pilot progress.',
|
||||
publicRoutePattern: (item) => `/dispatches/${dispatchSlug(item)}`,
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllDispatchesForAdmin(),
|
||||
columns: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: item.title,
|
||||
subtitle: item.author_name,
|
||||
}),
|
||||
},
|
||||
{
|
||||
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: 25,
|
||||
},
|
||||
|
||||
// Drafts in the sidebar light up terracotta until they're published.
|
||||
notifyCount: {
|
||||
count: (items) => items.filter((i) => i.status === 'draft').length,
|
||||
},
|
||||
|
||||
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: 'author_id',
|
||||
label: 'Author',
|
||||
kind: 'select-async',
|
||||
required: true,
|
||||
loadOptions: () =>
|
||||
getAllUsersPublic()
|
||||
.filter((u) => u.role === 'fenja')
|
||||
.map((u) => ({ value: u.id, label: u.name })),
|
||||
defaultValue: (ctx: FieldContext) => ctx.actingUserId,
|
||||
},
|
||||
{
|
||||
key: 'excerpt',
|
||||
label: 'Excerpt',
|
||||
kind: 'textarea',
|
||||
rows: 4,
|
||||
helperText:
|
||||
'Two to four sentences. The first sentence becomes the lead paragraph on the dispatch banner. Leave blank to fall back to the first ~200 chars of body.',
|
||||
},
|
||||
{
|
||||
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, kept here)' },
|
||||
],
|
||||
defaultValue: 'draft',
|
||||
helperText:
|
||||
'Switching from draft to published is the same as clicking the Publish action — the dispatch becomes visible to all members.',
|
||||
},
|
||||
],
|
||||
embeds: [{ key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }],
|
||||
},
|
||||
|
||||
ops: {
|
||||
getById: (id) => getDispatchById(id),
|
||||
|
||||
create: (data, ctx) => {
|
||||
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
|
||||
const status = (data.status as DispatchStatus) ?? 'draft';
|
||||
return createDispatch({
|
||||
title: String(data.title),
|
||||
body: String(data.body),
|
||||
excerpt: ((data.excerpt as string) ?? '').trim() || null,
|
||||
kind: data.kind as DispatchKind,
|
||||
author_id: Number(data.author_id),
|
||||
status,
|
||||
poll,
|
||||
});
|
||||
},
|
||||
|
||||
update: (id, data, ctx) => {
|
||||
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
|
||||
const current = getDispatchById(id);
|
||||
if (!current) throw new Error(`Dispatch ${id} not found`);
|
||||
|
||||
updateDispatch(id, {
|
||||
title: String(data.title),
|
||||
body: String(data.body),
|
||||
excerpt: ((data.excerpt as string) ?? '').trim() || null,
|
||||
kind: data.kind as DispatchKind,
|
||||
author_id: Number(data.author_id),
|
||||
poll,
|
||||
// Only flag pollExplicit when a question was actually submitted.
|
||||
// Empty pulse fields leave the existing pulse alone.
|
||||
pollExplicit: poll !== null,
|
||||
});
|
||||
|
||||
// Status transitions ride on the save submit.
|
||||
const desiredStatus = (data.status as DispatchStatus) ?? current.status;
|
||||
if (desiredStatus !== current.status) {
|
||||
if (desiredStatus === 'published') publishDispatch(id);
|
||||
else if (desiredStatus === 'archived') archiveDispatch(id);
|
||||
// 'draft' from another state is a no-op — there's no "unpublish".
|
||||
}
|
||||
},
|
||||
|
||||
delete: (id) => deleteDispatch(id),
|
||||
},
|
||||
|
||||
actions: [
|
||||
{
|
||||
key: 'publish',
|
||||
label: 'Publish now',
|
||||
visibleWhen: (item) => item.status === 'draft',
|
||||
confirmText: 'Publish this dispatch to all members?',
|
||||
handler: (id) => {
|
||||
publishDispatch(id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'archive',
|
||||
label: 'Archive',
|
||||
visibleWhen: (item) => item.status === 'published',
|
||||
destructive: true,
|
||||
confirmText: 'Archive this dispatch? It will be hidden from members.',
|
||||
handler: (id) => {
|
||||
archiveDispatch(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
250
src/admin/resources/events.ts
Normal file
250
src/admin/resources/events.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Events resource.
|
||||
*
|
||||
* Slug auto-generates from title on create when blank; on edit it's a regular
|
||||
* editable text field (changing it breaks any external links — admin's call).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
getEventById,
|
||||
getEventBySlug,
|
||||
getAllEvents,
|
||||
getEventRsvpCount,
|
||||
type Event,
|
||||
type EventKind,
|
||||
} from '../../lib/db';
|
||||
import { eventKindLabel } from '../../lib/format';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function uniqueEventSlug(base: string): string {
|
||||
let slug = base || 'event';
|
||||
let n = 1;
|
||||
while (getEventBySlug(slug)) {
|
||||
n += 1;
|
||||
slug = `${base}-${n}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function toSqliteDatetime(s: string): string {
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
|
||||
}
|
||||
|
||||
export const eventsResource: Resource<Event> = {
|
||||
key: 'events',
|
||||
label: 'Events',
|
||||
pluralLabel: 'Events',
|
||||
singularLabel: 'Event',
|
||||
groupKey: 'publishing',
|
||||
description: 'Gatherings, dinners, virtual sessions — anything that shows up at /events.',
|
||||
publicRoutePattern: (item) => `/events/${item.slug}`,
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllEvents(),
|
||||
columns: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: item.title,
|
||||
subtitle: item.location,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Kind',
|
||||
kind: 'pill',
|
||||
width: '140px',
|
||||
pillVariants: {
|
||||
dinner: { label: 'Dinner', class: 'pill-decision' },
|
||||
office_hours: { label: 'Studio hours', class: 'pill-update' },
|
||||
summit: { label: 'Summit', class: 'pill-note' },
|
||||
virtual: { label: 'Virtual', class: 'pill-bts' },
|
||||
working_session: { label: 'Working session', class: 'pill-considering' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'starts_at',
|
||||
label: 'Starts',
|
||||
kind: 'relative-date',
|
||||
width: '110px',
|
||||
emptyFallback: '—',
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
kind: 'number',
|
||||
width: '90px',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
predicate: () => true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
key: 'upcoming',
|
||||
label: 'Upcoming',
|
||||
predicate: (i) => new Date(i.starts_at).getTime() >= Date.now(),
|
||||
},
|
||||
{
|
||||
key: 'past',
|
||||
label: 'Past',
|
||||
predicate: (i) => new Date(i.starts_at).getTime() < Date.now(),
|
||||
},
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by title or location…',
|
||||
fields: ['title', 'location', 'description'],
|
||||
},
|
||||
defaultSort: { key: 'starts_at', direction: 'desc' },
|
||||
pageSize: 25,
|
||||
},
|
||||
|
||||
form: {
|
||||
fields: [
|
||||
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
||||
{
|
||||
key: 'slug',
|
||||
label: 'Slug',
|
||||
kind: 'text',
|
||||
maxLength: 80,
|
||||
helperText: 'URL path under /events/. Leave blank on create to auto-generate from the title.',
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Kind',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: (['dinner', 'office_hours', 'summit', 'virtual', 'working_session'] as EventKind[]).map(
|
||||
(k) => ({ value: k, label: eventKindLabel(k) }),
|
||||
),
|
||||
defaultValue: 'dinner',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
kind: 'textarea',
|
||||
rows: 5,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
maxLength: 200,
|
||||
helperText: 'Address, room, or video link.',
|
||||
},
|
||||
{ key: 'starts_at', label: 'Starts at', kind: 'datetime', required: true },
|
||||
{ key: 'ends_at', label: 'Ends at', kind: 'datetime' },
|
||||
{
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
kind: 'number',
|
||||
min: 0,
|
||||
max: 999,
|
||||
helperText: 'Leave blank for uncapped.',
|
||||
},
|
||||
{
|
||||
key: 'audience',
|
||||
label: 'Audience',
|
||||
kind: 'text',
|
||||
maxLength: 200,
|
||||
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
|
||||
},
|
||||
{
|
||||
key: 'duration_label',
|
||||
label: 'Duration label',
|
||||
kind: 'text',
|
||||
maxLength: 40,
|
||||
helperText: 'Optional display label like "90 min" or "Half day".',
|
||||
},
|
||||
{
|
||||
key: 'action_label',
|
||||
label: 'Action button label',
|
||||
kind: 'text',
|
||||
maxLength: 40,
|
||||
helperText: 'Override the default per-kind CTA (e.g. "Reserve your table").',
|
||||
},
|
||||
{
|
||||
key: 'photo_url',
|
||||
label: 'Photo URL',
|
||||
kind: 'text',
|
||||
maxLength: 400,
|
||||
helperText: 'Optional hero image for the event detail page.',
|
||||
},
|
||||
{
|
||||
key: 'notes_url',
|
||||
label: 'Notes URL',
|
||||
kind: 'text',
|
||||
maxLength: 400,
|
||||
helperText: 'Optional link to event notes published after the gathering.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
ops: {
|
||||
getById: (id) => getEventById(id),
|
||||
|
||||
create: (data, ctx) => {
|
||||
const rawSlug = ((data.slug as string) ?? '').trim();
|
||||
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
|
||||
return createEvent({
|
||||
slug,
|
||||
title: String(data.title),
|
||||
kind: data.kind as EventKind,
|
||||
description: String(data.description),
|
||||
location: String(data.location),
|
||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||
audience: ((data.audience as string) ?? '').trim() || null,
|
||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||
created_by: ctx.user.id,
|
||||
});
|
||||
},
|
||||
|
||||
update: (id, data) => {
|
||||
updateEvent(id, {
|
||||
title: String(data.title),
|
||||
kind: data.kind as EventKind,
|
||||
description: String(data.description),
|
||||
location: String(data.location),
|
||||
starts_at: toSqliteDatetime(String(data.starts_at)),
|
||||
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
|
||||
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
|
||||
photo_url: ((data.photo_url as string) ?? '').trim() || null,
|
||||
audience: ((data.audience as string) ?? '').trim() || null,
|
||||
duration_label: ((data.duration_label as string) ?? '').trim() || null,
|
||||
action_label: ((data.action_label as string) ?? '').trim() || null,
|
||||
notes_url: ((data.notes_url as string) ?? '').trim() || null,
|
||||
});
|
||||
},
|
||||
|
||||
delete: (id) => deleteEvent(id),
|
||||
},
|
||||
};
|
||||
|
||||
// Internal use, not exported on the resource — used by future row subtitles
|
||||
// if we want RSVP counts in the list view. Left here as a marker.
|
||||
export { getEventRsvpCount };
|
||||
33
src/admin/resources/index.ts
Normal file
33
src/admin/resources/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Resource registry — single source of truth for sidebar navigation.
|
||||
*
|
||||
* Groups are populated incrementally across steps 8–10 of the Backstage
|
||||
* rebuild. The display order inside each group matches sidebar order.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import type { ResourceGroup } from '../resource-types';
|
||||
import { dispatchesResource } from './dispatches';
|
||||
import { roadmapResource } from './roadmap';
|
||||
import { eventsResource } from './events';
|
||||
import { usersResource } from './users';
|
||||
import { invitationsResource } from './invitations';
|
||||
import { joinRequestsResource } from './join-requests';
|
||||
import { activityResource } from './activity';
|
||||
|
||||
export const groups: ResourceGroup[] = [
|
||||
{
|
||||
key: 'publishing',
|
||||
label: 'Publishing',
|
||||
resources: [dispatchesResource, roadmapResource, eventsResource],
|
||||
},
|
||||
{
|
||||
key: 'council',
|
||||
label: 'The council',
|
||||
resources: [usersResource, invitationsResource, joinRequestsResource],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: 'System',
|
||||
resources: [activityResource],
|
||||
},
|
||||
];
|
||||
192
src/admin/resources/invitations.ts
Normal file
192
src/admin/resources/invitations.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Invitations resource.
|
||||
*
|
||||
* Create surfaces the magic link via ctx.result.invite-link → the route
|
||||
* handler propagates it as ?invite_url=... and the edit panel renders a
|
||||
* copy-to-clipboard block. The token itself is never stored — only its
|
||||
* hash — so the link is shown exactly once.
|
||||
*
|
||||
* "Revoke" is implemented as an action (sets expires_at = now()), not as
|
||||
* ops.delete, because the row stays in the table for audit history.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
createInvite,
|
||||
getAllInvites,
|
||||
getInviteById,
|
||||
revokeInvite,
|
||||
type Invite,
|
||||
type Role,
|
||||
} from '../../lib/db';
|
||||
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
|
||||
import { relativeTime } from '../../lib/format';
|
||||
import { fmtDateTime } from '../../lib/markdown';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
type InviteRow = Invite & { creator_name: string | null };
|
||||
|
||||
function deriveStatus(item: InviteRow): 'pending' | 'accepted' | 'expired' {
|
||||
if (item.used_at) return 'accepted';
|
||||
if (new Date(item.expires_at).getTime() < Date.now()) return 'expired';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export const invitationsResource: Resource<InviteRow> = {
|
||||
key: 'invitations',
|
||||
label: 'Invitations',
|
||||
pluralLabel: 'Invitations',
|
||||
singularLabel: 'Invitation',
|
||||
groupKey: 'council',
|
||||
description: 'Magic links sent to new pilots and council members. Tokens are shown once on create.',
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllInvites(),
|
||||
columns: [
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: item.email,
|
||||
subtitle: `${item.name} · ${item.organisation || '—'}`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'pill',
|
||||
width: '110px',
|
||||
pillVariants: {
|
||||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||||
cab: { label: 'Council', class: 'pill-cab' },
|
||||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'creator_name',
|
||||
label: 'Invited by',
|
||||
width: '140px',
|
||||
render: (item) => ({ title: item.creator_name || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created',
|
||||
kind: 'relative-date',
|
||||
width: '110px',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
kind: 'pill',
|
||||
width: '110px',
|
||||
value: (item) => deriveStatus(item),
|
||||
pillVariants: {
|
||||
pending: { label: 'Pending', class: 'pill-pending' },
|
||||
accepted: { label: 'Accepted', class: 'pill-accepted' },
|
||||
expired: { label: 'Expired', class: 'pill-expired' },
|
||||
},
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||
{ key: 'pending', label: 'Pending', predicate: (i) => deriveStatus(i) === 'pending' },
|
||||
{ key: 'accepted', label: 'Accepted', predicate: (i) => deriveStatus(i) === 'accepted' },
|
||||
{ key: 'expired', label: 'Expired', predicate: (i) => deriveStatus(i) === 'expired' },
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by email or name…',
|
||||
fields: ['email', 'name', 'organisation'],
|
||||
},
|
||||
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||
pageSize: 50,
|
||||
},
|
||||
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
kind: 'text',
|
||||
required: true,
|
||||
maxLength: 240,
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
helperText: 'Where the magic link will land. Cannot be changed later.',
|
||||
},
|
||||
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
|
||||
{ key: 'organisation', label: 'Organisation', kind: 'text', maxLength: 200 },
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'pilot', label: 'Pilot' },
|
||||
{ value: 'cab', label: 'Council' },
|
||||
],
|
||||
defaultValue: 'pilot',
|
||||
helperText: 'Council invites allocate a member number on signup.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Existing invites are immutable — the panel renders the summary +
|
||||
// Revoke action via the review-mode pathway. Create still uses the form.
|
||||
summary: (item) => {
|
||||
const status = deriveStatus(item);
|
||||
return [
|
||||
{ label: 'Email', value: item.email },
|
||||
{ label: 'Name', value: item.name },
|
||||
{ label: 'Organisation', value: item.organisation || '—' },
|
||||
{
|
||||
label: 'Role',
|
||||
value: item.role === 'cab' ? 'Council' : item.role === 'pilot' ? 'Pilot' : 'Fenja team',
|
||||
},
|
||||
{ label: 'Status', value: status === 'pending' ? 'Pending' : status === 'accepted' ? 'Accepted' : 'Expired' },
|
||||
{ label: 'Invited by', value: item.creator_name ?? '—' },
|
||||
{ label: 'Created', value: relativeTime(item.created_at) },
|
||||
{ label: 'Expires', value: fmtDateTime(item.expires_at) },
|
||||
];
|
||||
},
|
||||
|
||||
ops: {
|
||||
getById: (id) => getInviteById(id),
|
||||
|
||||
create: (data, ctx) => {
|
||||
const { token, tokenHash } = generateInviteToken();
|
||||
const id = createInvite({
|
||||
token_hash: tokenHash,
|
||||
email: String(data.email).trim().toLowerCase(),
|
||||
name: String(data.name).trim(),
|
||||
organisation: ((data.organisation as string) ?? '').trim(),
|
||||
role: data.role as Role,
|
||||
expires_at: inviteExpiresAt(),
|
||||
created_by_user_id: ctx.user.id,
|
||||
});
|
||||
|
||||
// Surface the one-shot magic link via the result mechanism — the route
|
||||
// handler propagates it as ?invite_url= and the panel renders a copy
|
||||
// block on the next page load.
|
||||
const origin = process.env.PUBLIC_ORIGIN ?? '';
|
||||
ctx.result = {
|
||||
kind: 'invite-link',
|
||||
url: `${origin}/invite?t=${token}`,
|
||||
};
|
||||
return id;
|
||||
},
|
||||
},
|
||||
|
||||
actions: [
|
||||
{
|
||||
key: 'revoke',
|
||||
label: 'Revoke',
|
||||
visibleWhen: (item) => deriveStatus(item) === 'pending',
|
||||
destructive: true,
|
||||
confirmText:
|
||||
'Revoke this invitation? The magic link will stop working immediately.',
|
||||
handler: (id) => {
|
||||
revokeInvite(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
110
src/admin/resources/join-requests.ts
Normal file
110
src/admin/resources/join-requests.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Join requests resource — read-only review surface.
|
||||
*
|
||||
* The existing data model: a pilot user (already in the system) requests
|
||||
* promotion to council. The request joins to users for name/email/org;
|
||||
* there's no separate "stranger sign-up" model. As a result, the approval
|
||||
* flow upgrades the existing user's role rather than minting a fresh invite.
|
||||
*
|
||||
* Deviation from the original delta: no approve_as_pilot action (the
|
||||
* requester is already a pilot) and no invite-link result (the user already
|
||||
* exists). Stranger sign-ups would require a schema change to the
|
||||
* join_requests table — left for a future follow-up.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
getAllJoinRequests,
|
||||
getJoinRequestById,
|
||||
deleteJoinRequest,
|
||||
updateUserRole,
|
||||
type JoinRequest,
|
||||
} from '../../lib/db';
|
||||
import { relativeTime } from '../../lib/format';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
export const joinRequestsResource: Resource<JoinRequest> = {
|
||||
key: 'join_requests',
|
||||
label: 'Join requests',
|
||||
pluralLabel: 'Join requests',
|
||||
singularLabel: 'Join request',
|
||||
groupKey: 'council',
|
||||
description: 'Pilots asking to be upgraded to council. Approve to grant access, decline to dismiss.',
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllJoinRequests(),
|
||||
columns: [
|
||||
{
|
||||
key: 'user_name',
|
||||
label: 'Name',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({ title: item.user_name, subtitle: item.user_email }),
|
||||
},
|
||||
{
|
||||
key: 'user_organisation',
|
||||
label: 'Organisation',
|
||||
width: '1.5fr',
|
||||
render: (item) => ({ title: item.user_organisation || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Requested',
|
||||
kind: 'relative-date',
|
||||
width: '120px',
|
||||
},
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by name, email, organisation…',
|
||||
fields: ['user_name', 'user_email', 'user_organisation'],
|
||||
},
|
||||
defaultSort: { key: 'created_at', direction: 'desc' },
|
||||
pageSize: 50,
|
||||
},
|
||||
|
||||
// Read-only: no edit form, no create flow.
|
||||
form: null,
|
||||
|
||||
// Notify count = total pending requests (everything in the table is
|
||||
// pending under the current model — there's no status column yet).
|
||||
notifyCount: {
|
||||
count: (items) => items.length,
|
||||
},
|
||||
|
||||
// Review panel summary — shown when an item is clicked.
|
||||
summary: (item) => [
|
||||
{ label: 'Name', value: item.user_name },
|
||||
{ label: 'Email', value: item.user_email },
|
||||
{ label: 'Organisation', value: item.user_organisation || '—' },
|
||||
{ label: 'Requested', value: relativeTime(item.created_at) },
|
||||
],
|
||||
|
||||
ops: {
|
||||
getById: (id) => getJoinRequestById(id),
|
||||
// No delete in ops — declining is an action below, so the destructive
|
||||
// intent is named explicitly in the panel.
|
||||
},
|
||||
|
||||
actions: [
|
||||
{
|
||||
key: 'approve_as_cab',
|
||||
label: 'Approve as council',
|
||||
confirmText:
|
||||
'Promote this pilot to council? They will gain access to council-only surfaces.',
|
||||
handler: (id) => {
|
||||
const req = getJoinRequestById(id);
|
||||
if (!req) return;
|
||||
updateUserRole(req.user_id, 'cab');
|
||||
deleteJoinRequest(id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'decline',
|
||||
label: 'Decline',
|
||||
destructive: true,
|
||||
confirmText: 'Decline this request? It will be removed from the queue.',
|
||||
handler: (id) => {
|
||||
deleteJoinRequest(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
185
src/admin/resources/roadmap.ts
Normal file
185
src/admin/resources/roadmap.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Roadmap items resource.
|
||||
*
|
||||
* Attributed members come in via a multi-select-async loading all users; the
|
||||
* update handler calls setRoadmapAttributions() after the basic update so the
|
||||
* pivot table reflects the current selection.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
createRoadmapItem,
|
||||
updateRoadmapItem,
|
||||
deleteRoadmapItem,
|
||||
getRoadmapItem,
|
||||
getAllRoadmapItems,
|
||||
getAllUsersPublic,
|
||||
setRoadmapAttributions,
|
||||
type RoadmapItemWithAttribution,
|
||||
type RoadmapStatus,
|
||||
} from '../../lib/db';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
|
||||
key: 'roadmap',
|
||||
label: 'Roadmap',
|
||||
pluralLabel: 'Roadmap items',
|
||||
singularLabel: 'Roadmap item',
|
||||
groupKey: 'publishing',
|
||||
description: 'The route members see at /roadmap — what is shipping, in beta, exploring, or considered.',
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllRoadmapItems(),
|
||||
columns: [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: item.title,
|
||||
subtitle: item.description.slice(0, 80) + (item.description.length > 80 ? '…' : ''),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
kind: 'pill',
|
||||
width: '120px',
|
||||
pillVariants: {
|
||||
shipping: { label: 'Shipping', class: 'pill-shipping' },
|
||||
in_beta: { label: 'In beta', class: 'pill-in-beta' },
|
||||
exploring: { label: 'Exploring', class: 'pill-exploring' },
|
||||
considering: { label: 'Considering', class: 'pill-considering' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: 'Target',
|
||||
width: '140px',
|
||||
render: (item) => ({ title: item.target ?? '—' }),
|
||||
},
|
||||
{
|
||||
key: 'display_order',
|
||||
label: 'Order',
|
||||
kind: 'number',
|
||||
width: '70px',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
|
||||
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
|
||||
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
|
||||
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
|
||||
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by title or description…',
|
||||
fields: ['title', 'description'],
|
||||
},
|
||||
defaultSort: { key: 'display_order', direction: 'asc' },
|
||||
pageSize: 50,
|
||||
},
|
||||
|
||||
form: {
|
||||
fields: [
|
||||
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
kind: 'textarea',
|
||||
rows: 4,
|
||||
required: true,
|
||||
helperText: 'Shown on hover in the /roadmap route. Keep it to a sentence or two.',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'shipping', label: 'Shipping' },
|
||||
{ value: 'in_beta', label: 'In beta' },
|
||||
{ value: 'exploring', label: 'Exploring' },
|
||||
{ value: 'considering', label: 'Considering' },
|
||||
],
|
||||
defaultValue: 'exploring',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: 'Target',
|
||||
kind: 'text',
|
||||
maxLength: 80,
|
||||
helperText: 'Free-form quarter or date, e.g. "Q2 2026" or "Late May".',
|
||||
},
|
||||
{
|
||||
key: 'display_order',
|
||||
label: 'Display order',
|
||||
kind: 'number',
|
||||
min: 0,
|
||||
max: 999,
|
||||
defaultValue: 0,
|
||||
helperText: 'Lower numbers appear earlier in the route. Items with the same number fall back to creation order.',
|
||||
},
|
||||
{
|
||||
key: 'metadata_text',
|
||||
label: 'Hover metadata',
|
||||
kind: 'text',
|
||||
maxLength: 120,
|
||||
helperText: 'Short narrative cue shown on hover, e.g. "shipped 3 days ago".',
|
||||
},
|
||||
{
|
||||
key: 'attributed_members',
|
||||
label: 'Attributed members',
|
||||
kind: 'multi-select-async',
|
||||
loadOptions: () =>
|
||||
getAllUsersPublic().map((u) => ({ value: u.id, label: u.name })),
|
||||
helperText: 'Members credited for this item. Surfaces on their public profile and on the milestone card.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
ops: {
|
||||
getById: (id) => {
|
||||
const item = getRoadmapItem(id);
|
||||
if (!item) return null;
|
||||
// Expose attributed user-ids under the key the multi-select expects.
|
||||
return {
|
||||
...item,
|
||||
attributed_members: item.attributed.map((u) => u.id),
|
||||
} as unknown as RoadmapItemWithAttribution;
|
||||
},
|
||||
|
||||
create: (data) => {
|
||||
const id = createRoadmapItem({
|
||||
title: String(data.title),
|
||||
description: String(data.description),
|
||||
status: data.status as RoadmapStatus,
|
||||
target: ((data.target as string) ?? '').trim() || null,
|
||||
display_order: Number(data.display_order ?? 0),
|
||||
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||
});
|
||||
const userIds = Array.isArray(data.attributed_members)
|
||||
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||
: [];
|
||||
if (userIds.length > 0) setRoadmapAttributions(id, userIds);
|
||||
return id;
|
||||
},
|
||||
|
||||
update: (id, data) => {
|
||||
updateRoadmapItem(id, {
|
||||
title: String(data.title),
|
||||
description: String(data.description),
|
||||
status: data.status as RoadmapStatus,
|
||||
target: ((data.target as string) ?? '').trim() || null,
|
||||
display_order: Number(data.display_order ?? 0),
|
||||
metadata_text: ((data.metadata_text as string) ?? '').trim() || null,
|
||||
});
|
||||
const userIds = Array.isArray(data.attributed_members)
|
||||
? (data.attributed_members as unknown[]).map((v) => Number(v)).filter(Number.isFinite)
|
||||
: [];
|
||||
setRoadmapAttributions(id, userIds);
|
||||
},
|
||||
|
||||
delete: (id) => deleteRoadmapItem(id),
|
||||
},
|
||||
};
|
||||
324
src/admin/resources/users.ts
Normal file
324
src/admin/resources/users.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* People (users) resource — replaces the old Participants tab.
|
||||
*
|
||||
* Single resource for every user, regardless of role. The filter chips swap
|
||||
* the visible columns (council shows member_number + focus_tags; pilots/team
|
||||
* show role + last_seen_at). The edit panel's CAB-specific fields render
|
||||
* only when the user is in role=cab.
|
||||
*
|
||||
* Creation is intentionally absent — users come in through invites, not
|
||||
* direct admin creation. The "+ New" button is suppressed automatically
|
||||
* because ops.create is undefined.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
getAllUsersPublic,
|
||||
getUserPublicById,
|
||||
updateUserAdminFields,
|
||||
updateUserProfile,
|
||||
updateUserRole,
|
||||
deactivateUser,
|
||||
type Role,
|
||||
type UserPublic,
|
||||
} from '../../lib/db';
|
||||
import { parseFocusTags, readFocusTags } from '../../lib/format';
|
||||
import type { Resource } from '../resource-types';
|
||||
|
||||
const ROLE_LABEL: Record<Role, string> = {
|
||||
pilot: 'Pilot',
|
||||
cab: 'Council',
|
||||
fenja: 'Fenja team',
|
||||
};
|
||||
|
||||
const ROLE_PILL_CLASS: Record<Role, string> = {
|
||||
pilot: 'pill-pilot',
|
||||
cab: 'pill-cab',
|
||||
fenja: 'pill-fenja',
|
||||
};
|
||||
|
||||
export const usersResource: Resource<UserPublic> = {
|
||||
key: 'users',
|
||||
label: 'People',
|
||||
pluralLabel: 'People',
|
||||
singularLabel: 'Person',
|
||||
groupKey: 'council',
|
||||
description: 'Everyone with an account on the portal — pilots, council, and team.',
|
||||
publicRoutePattern: (item) => (item.slug ? `/members/${item.slug}` : null),
|
||||
|
||||
list: {
|
||||
queryFn: () => getAllUsersPublic(),
|
||||
|
||||
// Default columns shown under "Council" (the default filter): the CAB-
|
||||
// specific identity columns.
|
||||
columns: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({
|
||||
title: item.name,
|
||||
subtitle: item.email,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'member_number',
|
||||
label: 'Member #',
|
||||
kind: 'number',
|
||||
width: '100px',
|
||||
value: (item) => item.member_number,
|
||||
},
|
||||
{
|
||||
key: 'organisation',
|
||||
label: 'Organisation',
|
||||
width: '1.5fr',
|
||||
render: (item) => ({ title: item.organisation || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'focus_tags',
|
||||
label: 'Focus',
|
||||
kind: 'tag-list',
|
||||
width: '1.5fr',
|
||||
value: (item) => readFocusTags(item.focus_tags),
|
||||
},
|
||||
],
|
||||
|
||||
// Pilots / Team / All show role + organisation + last seen instead.
|
||||
columnsByFilter: {
|
||||
pilots: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'pill',
|
||||
width: '120px',
|
||||
pillVariants: {
|
||||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||||
cab: { label: 'Council', class: 'pill-cab' },
|
||||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'organisation',
|
||||
label: 'Organisation',
|
||||
width: '1.5fr',
|
||||
render: (item) => ({ title: item.organisation || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'last_seen_at',
|
||||
label: 'Last seen',
|
||||
kind: 'relative-date',
|
||||
width: '120px',
|
||||
emptyFallback: 'never',
|
||||
},
|
||||
],
|
||||
team: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'pill',
|
||||
width: '120px',
|
||||
pillVariants: {
|
||||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||||
cab: { label: 'Council', class: 'pill-cab' },
|
||||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'organisation',
|
||||
label: 'Organisation',
|
||||
width: '1.5fr',
|
||||
render: (item) => ({ title: item.organisation || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'last_seen_at',
|
||||
label: 'Last seen',
|
||||
kind: 'relative-date',
|
||||
width: '120px',
|
||||
emptyFallback: 'never',
|
||||
},
|
||||
],
|
||||
all: [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
primary: true,
|
||||
width: '2fr',
|
||||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'pill',
|
||||
width: '120px',
|
||||
pillVariants: {
|
||||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||||
cab: { label: 'Council', class: 'pill-cab' },
|
||||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'organisation',
|
||||
label: 'Organisation',
|
||||
width: '1.5fr',
|
||||
render: (item) => ({ title: item.organisation || '—' }),
|
||||
},
|
||||
{
|
||||
key: 'last_seen_at',
|
||||
label: 'Last seen',
|
||||
kind: 'relative-date',
|
||||
width: '120px',
|
||||
emptyFallback: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
filters: [
|
||||
{ key: 'council', label: 'Council', predicate: (i) => i.role === 'cab', isDefault: true },
|
||||
{ key: 'pilots', label: 'Pilots', predicate: (i) => i.role === 'pilot' },
|
||||
{ key: 'team', label: 'Team', predicate: (i) => i.role === 'fenja' },
|
||||
{ key: 'all', label: 'All', predicate: () => true },
|
||||
],
|
||||
search: {
|
||||
placeholder: 'Search by name, email, organisation…',
|
||||
fields: ['name', 'email', 'organisation'],
|
||||
},
|
||||
defaultSort: { key: 'name', direction: 'asc' },
|
||||
pageSize: 50,
|
||||
},
|
||||
|
||||
form: {
|
||||
fields: [
|
||||
// ── Always visible ───────────────────────────────────────────────
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
kind: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'pilot', label: 'Pilot' },
|
||||
{ value: 'cab', label: 'Council' },
|
||||
{ value: 'fenja', label: 'Fenja team' },
|
||||
],
|
||||
helperText:
|
||||
'Changing the role has real access consequences. Setting to Council also allocates a member number.',
|
||||
},
|
||||
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
|
||||
{ key: 'email', label: 'Email', kind: 'text', readOnly: true },
|
||||
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
|
||||
helperText: 'Set at sign-up; editing is not yet supported.' },
|
||||
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
|
||||
|
||||
// ── CAB-only ────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
kind: 'text',
|
||||
maxLength: 120,
|
||||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||||
},
|
||||
{
|
||||
key: 'pull_quote',
|
||||
label: 'Pull quote',
|
||||
kind: 'textarea',
|
||||
rows: 3,
|
||||
maxLength: 240,
|
||||
helperText: 'Shown on the member profile page. Two sentences max.',
|
||||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||||
},
|
||||
{
|
||||
key: 'focus_tags_text',
|
||||
label: 'Focus tags',
|
||||
kind: 'text',
|
||||
maxLength: 80,
|
||||
helperText:
|
||||
'Comma-separated. Up to 3 tags, 24 chars each. Normalised on save.',
|
||||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||||
},
|
||||
{
|
||||
key: 'cab_joined_date',
|
||||
label: 'Council joined',
|
||||
kind: 'readonly',
|
||||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||||
render: (value) => (value ? String(value) : '—'),
|
||||
},
|
||||
{
|
||||
key: 'member_number',
|
||||
label: 'Member number',
|
||||
kind: 'readonly',
|
||||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||||
render: (value) => (value ? `#${value}` : 'pending'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
ops: {
|
||||
getById: (id) => {
|
||||
const u = getUserPublicById(id);
|
||||
if (!u) return null;
|
||||
// Surface focus_tags as plaintext for the editor.
|
||||
return {
|
||||
...u,
|
||||
focus_tags_text: readFocusTags(u.focus_tags).join(', '),
|
||||
} as unknown as UserPublic;
|
||||
},
|
||||
|
||||
// No ops.create — users come in via invites.
|
||||
|
||||
update: (id, data) => {
|
||||
const current = getUserPublicById(id);
|
||||
if (!current) throw new Error(`User ${id} not found`);
|
||||
|
||||
// Profile fields (name + bio).
|
||||
const newName = String(data.name ?? current.name);
|
||||
const newBio = String(data.bio ?? current.bio ?? '');
|
||||
updateUserProfile(id, newName, newBio);
|
||||
|
||||
// Role transition — runs after profile update so member_number can be
|
||||
// allocated against an up-to-date user row.
|
||||
const newRole = data.role as Role;
|
||||
if (newRole && newRole !== current.role) {
|
||||
updateUserRole(id, newRole);
|
||||
}
|
||||
|
||||
// CAB-specific admin fields. Only applied when the user is CAB after
|
||||
// the role update; otherwise the form fields aren't visible.
|
||||
const isCab = (newRole ?? current.role) === 'cab';
|
||||
if (isCab) {
|
||||
const tagsRaw = String(data.focus_tags_text ?? '');
|
||||
updateUserAdminFields(id, {
|
||||
title: ((data.title as string) ?? '').trim() || null,
|
||||
pull_quote: ((data.pull_quote as string) ?? '').trim() || null,
|
||||
focus_tags: parseFocusTags(tagsRaw),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
delete: (id) => deactivateUser(id),
|
||||
},
|
||||
|
||||
notifyCount: {
|
||||
// CAB members without focus tags read as half-finished profiles —
|
||||
// surface them as something to attend to.
|
||||
count: (items) =>
|
||||
items.filter(
|
||||
(u) => u.role === 'cab' && readFocusTags(u.focus_tags).length === 0,
|
||||
).length,
|
||||
},
|
||||
};
|
||||
|
||||
// Keep the role label map exported for any callers that want display copy.
|
||||
export { ROLE_LABEL, ROLE_PILL_CLASS };
|
||||
116
src/admin/validate.ts
Normal file
116
src/admin/validate.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* ---------------------------------------------------------------------------
|
||||
* Form validation derived from a Resource's field definitions.
|
||||
*
|
||||
* Returns a map of field.key → error message. Empty object = valid.
|
||||
* Server-side authoritative; the client may show hints, but every POST
|
||||
* must call this before writing to the DB.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import type { Field, FieldContext, Resource } from './resource-types';
|
||||
|
||||
export type ValidationErrors = Record<string, string>;
|
||||
|
||||
export interface ValidateArgs {
|
||||
resource: Resource;
|
||||
data: Record<string, unknown>;
|
||||
/** Existing item being edited, or null on create. */
|
||||
item: Record<string, unknown> | null;
|
||||
actingUserId: number;
|
||||
}
|
||||
|
||||
export function validateForResource(args: ValidateArgs): ValidationErrors {
|
||||
const { resource, data, item, actingUserId } = args;
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
if (!resource.form) return errors;
|
||||
|
||||
const ctx: FieldContext = { formValues: data, item, actingUserId };
|
||||
|
||||
for (const field of resource.form.fields) {
|
||||
if (field.readOnly) continue;
|
||||
if (field.visibleWhen && !field.visibleWhen(ctx)) continue;
|
||||
|
||||
const value = data[field.key];
|
||||
const error = validateField(field, value);
|
||||
if (error) errors[field.key] = error;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateField(field: Field, value: unknown): string | null {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (field.required && isEmpty) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
if (isEmpty) return null;
|
||||
|
||||
switch (field.kind) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'markdown': {
|
||||
if (typeof value !== 'string') return `${field.label} must be text`;
|
||||
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
|
||||
return `${field.label} must be ${field.maxLength} characters or fewer`;
|
||||
}
|
||||
if (field.kind === 'text' && field.pattern && !field.pattern.test(value)) {
|
||||
return `${field.label} is not in the expected format`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'select':
|
||||
case 'select-async': {
|
||||
// Empty already handled. Anything else is accepted at the validate layer;
|
||||
// option membership is enforced by the route handler against a fresh option list.
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'multi-select-async': {
|
||||
if (!Array.isArray(value)) return `${field.label} must be a list`;
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'multi-text': {
|
||||
if (!Array.isArray(value)) return `${field.label} must be a list`;
|
||||
const filled = value.filter(
|
||||
(v) => typeof v === 'string' && v.trim() !== '',
|
||||
);
|
||||
if (field.minItems !== undefined && filled.length < field.minItems) {
|
||||
return `${field.label} requires at least ${field.minItems} entries`;
|
||||
}
|
||||
if (field.maxItems !== undefined && filled.length > field.maxItems) {
|
||||
return `${field.label} allows at most ${field.maxItems} entries`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return `${field.label} must be a number`;
|
||||
if (field.min !== undefined && n < field.min) {
|
||||
return `${field.label} must be at least ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && n > field.max) {
|
||||
return `${field.label} must be no more than ${field.max}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'date':
|
||||
case 'datetime': {
|
||||
if (typeof value !== 'string') return `${field.label} must be a date`;
|
||||
const t = Date.parse(value);
|
||||
if (Number.isNaN(t)) return `${field.label} is not a valid date`;
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'readonly':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -304,6 +304,15 @@ export function getAllInvites(): (Invite & { creator_name: string | null })[] {
|
|||
`).all() as (Invite & { creator_name: string | null })[];
|
||||
}
|
||||
|
||||
export function getInviteById(id: number): (Invite & { creator_name: string | null }) | null {
|
||||
return db.prepare(`
|
||||
SELECT i.*, u.name AS creator_name
|
||||
FROM invites i
|
||||
LEFT JOIN users u ON u.id = i.created_by_user_id
|
||||
WHERE i.id = ?
|
||||
`).get(id) as (Invite & { creator_name: string | null }) | null;
|
||||
}
|
||||
|
||||
// ── Contributions ────────────────────────────────────────────────
|
||||
|
||||
export function createContribution(data: {
|
||||
|
|
@ -457,6 +466,20 @@ export function getAllJoinRequests(): JoinRequest[] {
|
|||
`).all() as JoinRequest[];
|
||||
}
|
||||
|
||||
export function getJoinRequestById(id: number): JoinRequest | null {
|
||||
return db.prepare(`
|
||||
SELECT jr.id, jr.user_id, jr.created_at,
|
||||
u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation
|
||||
FROM join_requests jr
|
||||
JOIN users u ON u.id = jr.user_id
|
||||
WHERE jr.id = ?
|
||||
`).get(id) as JoinRequest | null;
|
||||
}
|
||||
|
||||
export function deleteJoinRequest(id: number): void {
|
||||
db.prepare('DELETE FROM join_requests WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ── Date helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */
|
||||
|
|
|
|||
253
src/pages/admin/[resource].astro
Normal file
253
src/pages/admin/[resource].astro
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
---
|
||||
/* ---------------------------------------------------------------------------
|
||||
* /admin/<resource> — the production dynamic admin route.
|
||||
*
|
||||
* Resolves the resource from the URL segment, gates on user.role === 'fenja',
|
||||
* dispatches POSTs (save / delete / action key) through validateForResource
|
||||
* and resource.ops, redirects with a ?msg=<key> flash on success.
|
||||
*
|
||||
* 404s when the resource key is not registered — step 8 onward populates
|
||||
* the registry; until then most resource keys won't resolve.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
import AdminLayout from '../../admin/components/AdminLayout.astro';
|
||||
import ResourceListView from '../../admin/components/ResourceListView.astro';
|
||||
import ResourceEditPanel from '../../admin/components/ResourceEditPanel.astro';
|
||||
import { groups } from '../../admin/resources';
|
||||
import { validateForResource, type ValidationErrors } from '../../admin/validate';
|
||||
import type {
|
||||
ActionResult,
|
||||
Field,
|
||||
OpContext,
|
||||
Resource,
|
||||
} from '../../admin/resource-types';
|
||||
|
||||
// ── Auth gate ─────────────────────────────────────────────────────────────
|
||||
const user = Astro.locals.user;
|
||||
if (user.role !== 'fenja') return Astro.redirect('/');
|
||||
|
||||
// ── Resolve resource from URL segment ─────────────────────────────────────
|
||||
const resourceKey = Astro.params.resource;
|
||||
const allResources = groups.flatMap((g) => g.resources);
|
||||
const resource = allResources.find((r) => r.key === resourceKey) as
|
||||
| Resource
|
||||
| undefined;
|
||||
|
||||
if (!resource) {
|
||||
return new Response('Resource not found', { status: 404 });
|
||||
}
|
||||
|
||||
const resourceBase = `/admin/${resource.key}`;
|
||||
const opCtx: OpContext = { user: { id: user.id, role: user.role } };
|
||||
|
||||
// ── Form-data → typed record (driven by the field configs) ────────────────
|
||||
function parseFormData(
|
||||
formData: FormData,
|
||||
fields: Field[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if (field.readOnly) continue;
|
||||
|
||||
switch (field.kind) {
|
||||
case 'multi-text': {
|
||||
out[field.key] = formData
|
||||
.getAll(field.key)
|
||||
.map((v) => String(v))
|
||||
.filter((v) => v.trim() !== '');
|
||||
break;
|
||||
}
|
||||
case 'multi-select-async': {
|
||||
out[field.key] = formData.getAll(field.key).map((v) => {
|
||||
const s = String(v);
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) && s !== '' ? n : s;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'number': {
|
||||
const v = formData.get(field.key);
|
||||
if (v == null || v === '') {
|
||||
out[field.key] = null;
|
||||
} else {
|
||||
const n = Number(v);
|
||||
out[field.key] = Number.isFinite(n) ? n : v;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const v = formData.get(field.key);
|
||||
out[field.key] = v == null ? '' : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── State that survives a failed POST (so the panel re-fills) ─────────────
|
||||
let errors: ValidationErrors = {};
|
||||
let formError: string | null = null;
|
||||
let resubmitValues: Record<string, unknown> | null = null;
|
||||
|
||||
// ── POST dispatch ────────────────────────────────────────────────────────
|
||||
if (Astro.request.method === 'POST') {
|
||||
const formData = await Astro.request.formData();
|
||||
const action = String(formData.get('_action') ?? 'save');
|
||||
opCtx.formData = formData;
|
||||
|
||||
const editIdParam = Astro.url.searchParams.get('edit');
|
||||
const editId =
|
||||
editIdParam && Number.isFinite(Number(editIdParam))
|
||||
? Number(editIdParam)
|
||||
: null;
|
||||
|
||||
// ── save ──────────────────────────────────────────────────────────────
|
||||
if (action === 'save') {
|
||||
if (!resource.form) {
|
||||
return new Response('Resource is read-only', { status: 403 });
|
||||
}
|
||||
|
||||
const data = parseFormData(formData, resource.form.fields);
|
||||
const existingItem =
|
||||
editId !== null && resource.ops.getById
|
||||
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
|
||||
: null;
|
||||
|
||||
errors = validateForResource({
|
||||
resource,
|
||||
data,
|
||||
item: existingItem,
|
||||
actingUserId: user.id,
|
||||
});
|
||||
|
||||
if (Object.keys(errors).length === 0) {
|
||||
try {
|
||||
if (editId !== null && resource.ops.update) {
|
||||
await resource.ops.update(editId, data, opCtx);
|
||||
const extra = resultRedirectParam(opCtx.result);
|
||||
return Astro.redirect(`${resourceBase}?edit=${editId}&msg=saved${extra}`);
|
||||
}
|
||||
if (editId === null && resource.ops.create) {
|
||||
const newId = await resource.ops.create(data, opCtx);
|
||||
const extra = resultRedirectParam(opCtx.result);
|
||||
return Astro.redirect(`${resourceBase}?edit=${newId}&msg=created${extra}`);
|
||||
}
|
||||
return Astro.redirect(`${resourceBase}?msg=saved`);
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Save failed';
|
||||
resubmitValues = data;
|
||||
}
|
||||
} else {
|
||||
resubmitValues = data;
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────
|
||||
else if (action === 'delete') {
|
||||
if (editId !== null && resource.ops.delete) {
|
||||
try {
|
||||
await resource.ops.delete(editId, opCtx);
|
||||
return Astro.redirect(`${resourceBase}?msg=deleted`);
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Delete failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── custom action ─────────────────────────────────────────────────────
|
||||
else {
|
||||
const customAction = resource.actions?.find((a) => a.key === action);
|
||||
if (customAction && editId !== null) {
|
||||
try {
|
||||
const direct = await customAction.handler(editId, opCtx);
|
||||
// Handlers may set ctx.result or return an ActionResult — accept both.
|
||||
const result = (direct as ActionResult | undefined) ?? opCtx.result;
|
||||
const extra = resultRedirectParam(result);
|
||||
// Some actions remove the item entirely (e.g. decline). Land on the
|
||||
// list view in that case so we don't 404 trying to re-fetch the row.
|
||||
const stillExists = resource.ops.getById
|
||||
? (await resource.ops.getById(editId)) !== null
|
||||
: true;
|
||||
const target = stillExists
|
||||
? `${resourceBase}?edit=${editId}&msg=action_${encodeURIComponent(action)}${extra}`
|
||||
: `${resourceBase}?msg=action_${encodeURIComponent(action)}${extra}`;
|
||||
return Astro.redirect(target);
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Action failed';
|
||||
}
|
||||
} else {
|
||||
return new Response('Unknown action', { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resultRedirectParam(r: ActionResult | undefined): string {
|
||||
if (!r) return '';
|
||||
if (r.kind === 'invite-link') {
|
||||
return `&invite_url=${encodeURIComponent(r.url)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── GET / failed-POST render ──────────────────────────────────────────────
|
||||
const isNew = Astro.url.searchParams.get('new') === '1';
|
||||
const editIdRaw = Astro.url.searchParams.get('edit');
|
||||
const editId =
|
||||
editIdRaw && Number.isFinite(Number(editIdRaw)) ? Number(editIdRaw) : null;
|
||||
|
||||
const editingItem =
|
||||
editId !== null && resource.ops.getById
|
||||
? ((await resource.ops.getById(editId)) as Record<string, unknown> | null)
|
||||
: null;
|
||||
|
||||
// Panel renders when:
|
||||
// - editing/creating a form-bearing resource, OR
|
||||
// - reviewing an item from a form-null resource that has a summary (e.g. join_requests)
|
||||
const showPanel = resource.form !== null
|
||||
? (isNew || editingItem !== null)
|
||||
: (editingItem !== null && resource.summary !== undefined);
|
||||
|
||||
const msg = Astro.url.searchParams.get('msg');
|
||||
const pageTitle = `${resource.pluralLabel} — Backstage`;
|
||||
|
||||
// Friendly flash text. Anything action_<key> is rendered as
|
||||
// "<action.label> done." using the resource's action label.
|
||||
function flashTextFor(rawMsg: string | null): string | null {
|
||||
if (!rawMsg) return null;
|
||||
if (formError) return formError;
|
||||
if (rawMsg.startsWith('action_')) {
|
||||
const key = rawMsg.slice('action_'.length);
|
||||
const action = resource!.actions?.find((a) => a.key === key);
|
||||
return action ? `${action.label}.` : null;
|
||||
}
|
||||
return ({
|
||||
saved: 'Saved.',
|
||||
created: 'Created.',
|
||||
deleted: 'Deleted.',
|
||||
} as Record<string, string>)[rawMsg] ?? null;
|
||||
}
|
||||
const flash = formError ?? flashTextFor(msg);
|
||||
const flashKind = formError ? 'error' : 'success';
|
||||
---
|
||||
|
||||
<AdminLayout
|
||||
title={pageTitle}
|
||||
groups={groups}
|
||||
activeResourceKey={resource.key}
|
||||
>
|
||||
{flash && (
|
||||
<div class:list={['bs-flash', flashKind]} role="status">{flash}</div>
|
||||
)}
|
||||
|
||||
<ResourceListView resource={resource} groups={groups} />
|
||||
|
||||
{showPanel && (
|
||||
<ResourceEditPanel
|
||||
resource={resource}
|
||||
item={editingItem}
|
||||
formValues={resubmitValues ?? undefined}
|
||||
errors={errors}
|
||||
actingUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
</AdminLayout>
|
||||
|
|
@ -1,904 +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';
|
||||
/* ---------------------------------------------------------------------------
|
||||
* /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>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.page-title { margin: 0; }
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-8);
|
||||
border-bottom: var(--ghost-border);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
transition: color var(--duration-fast) var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.tab:hover { color: var(--on-surface-variant); background: var(--surface-container-low); border-bottom: none; }
|
||||
.tab.active { color: var(--on-surface); background: var(--surface-container); }
|
||||
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--secondary);
|
||||
color: var(--on-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-label-sm);
|
||||
font-weight: 700;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 var(--space-1);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.section-note {
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
max-width: var(--reading-max);
|
||||
}
|
||||
|
||||
/* ── Messages ────────────────────────────────────────────────────── */
|
||||
.action-msg {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(109, 140, 124, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--pigment-copper);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(185, 107, 88, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--pigment-terracotta);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Tab content ─────────────────────────────────────────────────── */
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Invite result ───────────────────────────────────────────────── */
|
||||
.invite-result {
|
||||
background: rgba(109, 140, 124, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.invite-result-label {
|
||||
color: var(--pigment-copper);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invite-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.invite-link {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--background);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--on-surface);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--secondary);
|
||||
color: var(--on-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Invite form ─────────────────────────────────────────────────── */
|
||||
.invite-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.input,
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-container-lowest);
|
||||
border: var(--ghost-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body-md);
|
||||
color: var(--on-surface);
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||
box-shadow var(--duration-fast) var(--ease-standard);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus {
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-2) var(--space-6);
|
||||
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
|
||||
color: var(--on-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
/* ── Data table ──────────────────────────────────────────────────── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
padding: var(--space-2) var(--space-3) var(--space-2) 0;
|
||||
border-bottom: var(--ghost-border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: var(--space-3) var(--space-3) var(--space-3) 0;
|
||||
border-bottom: var(--ghost-border);
|
||||
color: var(--on-surface-variant);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tr.self td {
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.muted { color: var(--on-surface-muted) !important; }
|
||||
|
||||
/* ── Inline elements ─────────────────────────────────────────────── */
|
||||
.inline-form { display: inline; }
|
||||
|
||||
.select-inline {
|
||||
background: none;
|
||||
border: var(--ghost-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
padding: 0.2em var(--space-3);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--pigment-terracotta);
|
||||
padding: 0.2em var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.danger-btn:hover {
|
||||
background: rgba(185, 107, 88, 0.08);
|
||||
}
|
||||
|
||||
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
.action-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--on-surface-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: none;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-label-md);
|
||||
transition: color var(--duration-fast) var(--ease-standard);
|
||||
padding: 0;
|
||||
}
|
||||
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
||||
</style>
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
113
tests/admin-validate.test.ts
Normal file
113
tests/admin-validate.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { validateForResource } from '../src/admin/validate';
|
||||
import type { Resource } from '../src/admin/resource-types';
|
||||
|
||||
// Minimal resource fixture covering the field kinds the validator handles.
|
||||
const resource: Resource = {
|
||||
key: 'fixtures',
|
||||
label: 'Fixture',
|
||||
pluralLabel: 'Fixtures',
|
||||
singularLabel: 'Fixture',
|
||||
groupKey: 'system',
|
||||
list: { queryFn: () => [], columns: [] },
|
||||
ops: {},
|
||||
form: {
|
||||
fields: [
|
||||
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 5 },
|
||||
{ key: 'kind', label: 'Kind', kind: 'select', options: [{ value: 'a', label: 'A' }] },
|
||||
{
|
||||
key: 'options',
|
||||
label: 'Options',
|
||||
kind: 'multi-text',
|
||||
minItems: 2,
|
||||
maxItems: 4,
|
||||
},
|
||||
{ key: 'count', label: 'Count', kind: 'number', min: 0, max: 10 },
|
||||
{
|
||||
key: 'extras',
|
||||
label: 'Extras',
|
||||
kind: 'text',
|
||||
// Only visible when kind === 'a'
|
||||
visibleWhen: (ctx) => ctx.formValues.kind === 'a',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const baseArgs = { resource, item: null, actingUserId: 1 };
|
||||
|
||||
describe('validateForResource', () => {
|
||||
it('reports required fields that are empty', () => {
|
||||
const errors = validateForResource({ ...baseArgs, data: {} });
|
||||
expect(errors.title).toBe('Title is required');
|
||||
});
|
||||
|
||||
it('reports maxLength violations on text fields', () => {
|
||||
const errors = validateForResource({ ...baseArgs, data: { title: 'too long' } });
|
||||
expect(errors.title).toMatch(/5 characters or fewer/);
|
||||
});
|
||||
|
||||
it('skips fields hidden by visibleWhen', () => {
|
||||
// kind = 'b' hides the 'extras' required field — no error expected
|
||||
const errors = validateForResource({
|
||||
...baseArgs,
|
||||
data: { title: 'ok', kind: 'b' },
|
||||
});
|
||||
expect(errors.extras).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enforces visibleWhen-revealed required fields', () => {
|
||||
const errors = validateForResource({
|
||||
...baseArgs,
|
||||
data: { title: 'ok', kind: 'a' },
|
||||
});
|
||||
expect(errors.extras).toBe('Extras is required');
|
||||
});
|
||||
|
||||
it('enforces multi-text min/max items', () => {
|
||||
const tooFew = validateForResource({
|
||||
...baseArgs,
|
||||
data: { title: 'ok', options: ['only-one'] },
|
||||
});
|
||||
expect(tooFew.options).toMatch(/at least 2/);
|
||||
|
||||
const tooMany = validateForResource({
|
||||
...baseArgs,
|
||||
data: { title: 'ok', options: ['1', '2', '3', '4', '5'] },
|
||||
});
|
||||
expect(tooMany.options).toMatch(/at most 4/);
|
||||
});
|
||||
|
||||
it('enforces number min/max', () => {
|
||||
const tooHigh = validateForResource({
|
||||
...baseArgs,
|
||||
data: { title: 'ok', count: 99 },
|
||||
});
|
||||
expect(tooHigh.count).toMatch(/no more than 10/);
|
||||
});
|
||||
|
||||
it('returns no errors when every field is valid', () => {
|
||||
const errors = validateForResource({
|
||||
...baseArgs,
|
||||
data: {
|
||||
title: 'ok',
|
||||
kind: 'a',
|
||||
options: ['x', 'y'],
|
||||
count: 5,
|
||||
extras: 'fine',
|
||||
},
|
||||
});
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
|
||||
it('treats form: null as always valid', () => {
|
||||
const readOnly: Resource = { ...resource, form: null };
|
||||
const errors = validateForResource({
|
||||
...baseArgs,
|
||||
resource: readOnly,
|
||||
data: {},
|
||||
});
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue