Three more resources land. /admin/users replaces the old participants
tab, /admin/invitations replaces the old invites tab, /admin/join_requests
replaces the read-only join queue.
- src/admin/resources/users.ts ("People"): single resource for all users,
filter chips swap visible columns (council shows member_number +
focus_tags; pilots/team show role + last_seen_at). Form fields are
conditional — title / pull_quote / focus_tags / cab_joined_date /
member_number render only when role === cab. No ops.create (users
come via invites); deactivateUser is the delete handler.
- src/admin/resources/invitations.ts: form-for-create, summary-for-view.
Create generates a token via generateInviteToken(), stores its hash,
surfaces the magic link as a one-shot ?invite_url= block in the panel.
Revoke is an action (sets expires_at = now); the row stays for audit.
- src/admin/resources/join-requests.ts: form: null, review-mode panel
with the user's summary + approve_as_cab / decline actions.
Plumbing to support the above:
- src/admin/resource-types.ts: new Resource.summary callback (read-only
field pairs for review panels); OpContext.result lets ops surface
ActionResults (e.g. invite-link).
- src/admin/components/ResourceEditPanel.astro: review mode when an
existing item is shown and resource.summary is defined; renders the
?invite_url= block above the summary with a copy-to-clipboard button.
- src/admin/components/ResourceListView.astro: "+ New" suppressed when
ops.create is undefined.
- src/pages/admin/[resource].astro: captures ctx.result and action
handler return values, propagates them via &invite_url=...; routes to
the list view (not the row) when an action removes the item.
- src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest,
getInviteById.
Deviation from the original delta: no approve_as_pilot action and no
invite-link result on join-request approval. The existing
join_requests schema only stores user_id — requests come from
already-authenticated pilots asking for a CAB upgrade, not from
strangers needing an invite. The schema change for stranger sign-ups
is left for a future follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
8 KiB
Text
205 lines
8 KiB
Text
---
|
|
/* ---------------------------------------------------------------------------
|
|
* 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;
|
|
---
|
|
|
|
<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);
|
|
return (
|
|
<a
|
|
href={withParams({ edit: id, new: null })}
|
|
class="bs-grid-row"
|
|
role="row"
|
|
aria-label={`Edit ${resource.singularLabel.toLowerCase()} ${id}`}
|
|
>
|
|
{columns.map((col) => (
|
|
<div class="bs-grid-td" role="cell">
|
|
<ListCell column={col} item={item} />
|
|
</div>
|
|
))}
|
|
</a>
|
|
);
|
|
})}
|
|
</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>
|