feat(admin): AdminLayout shell + empty resource registry

Two-pane Backstage chrome: sticky topbar with the wordmark + " /
Backstage" lockup and a "Back to the portal" link, plus a left
sidebar that walks the resource registry and renders grouped
links with active-state and count badges.

- src/admin/components/AdminLayout.astro — the shell. Pre-resolves
  list-counts and notify-counts per resource so the sidebar can
  render badges without async work in markup. Renders an empty
  state until resources land.
- src/admin/resources/index.ts — empty registry stub. Three groups
  declared (publishing, council, system); resources populated in
  steps 8–10.
- src/admin/admin.css — Backstage tokens (--admin-sidebar-bg,
  --admin-active-accent, etc.) and the shell styles (bs-topbar,
  bs-sidebar, bs-resource, bs-count). Mobile collapses the sidebar
  above the main pane.
- src/pages/admin/preview.astro — temporary smoke-test route at
  /admin/preview. Deleted in step 11 when the new admin replaces
  the old one.

Old /admin (?tab=…) is untouched and continues to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 16:00:57 +02:00
parent ea056fff7b
commit dd7215d828
4 changed files with 410 additions and 0 deletions

View file

@ -304,3 +304,238 @@
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
/* ===========================================================================
* Backstage shell added in step 4 of the admin rebuild.
*
* Tokens specific to the admin surface. Changing the accent or panel chrome
* later means editing one variable here, not every component.
* ========================================================================= */
:root {
--admin-sidebar-bg: var(--background);
--admin-sidebar-border: rgba(0, 0, 0, 0.06);
--admin-row-border: rgba(0, 0, 0, 0.05);
--admin-row-hover: rgba(0, 0, 0, 0.02);
--admin-active-accent: var(--pigment-terracotta);
--admin-panel-bg: var(--surface-container-lowest);
--admin-panel-shadow: 0 20px 60px -20px rgba(42, 37, 32, 0.25);
--admin-topbar-h: 56px;
--admin-sidebar-w: 240px;
}
.backstage {
min-height: 100vh;
background: var(--background);
display: flex;
flex-direction: column;
}
/* ── Top strip ───────────────────────────────────────────────────── */
.bs-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--admin-topbar-h);
padding: 0 var(--space-8);
border-bottom: 1px solid var(--admin-sidebar-border);
background: var(--background);
position: sticky;
top: 0;
z-index: 50;
}
.bs-brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--on-surface);
border-bottom: none;
line-height: 1;
}
.bs-brand:hover { color: var(--on-surface); border-bottom: none; }
.bs-brand-mark {
height: 18px;
width: auto;
display: block;
}
.bs-brand-sep,
.bs-brand-slash {
color: var(--on-surface-muted);
font-family: var(--font-serif);
font-size: 16px;
line-height: 1;
transform: translateY(-1px);
}
.bs-brand-project,
.bs-brand-bifrost,
.bs-brand-backstage {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
line-height: 1.4;
}
.bs-brand-project {
font-size: 16px;
color: var(--on-surface);
}
.bs-brand-bifrost {
display: inline-block;
font-size: 14px;
font-style: italic;
padding: 3px 0 1px;
background-image: linear-gradient(
90deg,
var(--pigment-terracotta) 0%,
var(--pigment-ochre) 28%,
var(--pigment-copper) 54%,
var(--pigment-indigo) 78%,
var(--pigment-heather) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.bs-brand-backstage {
font-size: 16px;
color: var(--on-surface-muted);
font-style: italic;
}
.bs-back-link {
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
transition: color var(--duration-fast) var(--ease-standard);
}
.bs-back-link:hover { color: var(--on-surface); border-bottom: none; }
/* ── Two-pane body ───────────────────────────────────────────────── */
.bs-body {
display: grid;
grid-template-columns: var(--admin-sidebar-w) 1fr;
min-height: calc(100vh - var(--admin-topbar-h));
}
/* ── Sidebar ─────────────────────────────────────────────────────── */
.bs-sidebar {
background: var(--admin-sidebar-bg);
border-right: 1px solid var(--admin-sidebar-border);
padding: var(--space-6) 0;
position: sticky;
top: var(--admin-topbar-h);
align-self: start;
height: calc(100vh - var(--admin-topbar-h));
overflow-y: auto;
}
.bs-groups {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.bs-group-label {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0 0 var(--space-2);
padding: 0 var(--space-6);
}
.bs-resources {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.bs-resource {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 7px var(--space-6);
font-family: var(--font-sans);
font-size: 13px;
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
border-left: 2px solid transparent;
transition: background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.bs-resource:hover {
background: var(--admin-row-hover);
color: var(--on-surface);
border-bottom: none;
}
.bs-resource.active {
border-left-color: var(--admin-active-accent);
color: var(--on-surface);
background: var(--admin-row-hover);
}
.bs-resource-label { line-height: 1.4; }
.bs-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: transparent;
color: var(--on-surface-muted);
font-size: 11px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.bs-count.notify {
color: var(--admin-active-accent);
font-weight: 600;
}
.bs-sidebar-empty {
color: var(--on-surface-muted);
font-size: 13px;
padding: 0 var(--space-6);
margin: 0;
font-style: italic;
}
/* ── Main pane ──────────────────────────────────────────────────── */
.bs-main {
padding: var(--space-8) var(--space-10);
min-width: 0; /* prevents grid blowout on long content */
}
/* ── Responsive — mobile shows the sidebar above the content ────── */
@media (max-width: 767px) {
.bs-body {
grid-template-columns: 1fr;
}
.bs-sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--admin-sidebar-border);
padding: var(--space-4) 0;
}
.bs-main {
padding: var(--space-6) var(--space-6);
}
}

View 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>

View file

@ -0,0 +1,15 @@
/* ---------------------------------------------------------------------------
* Resource registry single source of truth for sidebar navigation.
*
* Groups are populated incrementally across steps 810 of the Backstage
* rebuild. Empty registration is intentional during the shell-only phase;
* AdminLayout renders the empty state until the first resource lands.
* ------------------------------------------------------------------------- */
import type { ResourceGroup } from '../resource-types';
export const groups: ResourceGroup[] = [
{ key: 'publishing', label: 'Publishing', resources: [] },
{ key: 'council', label: 'The council', resources: [] },
{ key: 'system', label: 'System', resources: [] },
];

View file

@ -0,0 +1,39 @@
---
/* ---------------------------------------------------------------------------
* /admin/preview — temporary smoke route for the Backstage shell.
*
* Mounts AdminLayout against the resource registry so the chrome can be
* visually verified before any resources are registered. Deleted in step 11
* once the new admin replaces the old.
* ------------------------------------------------------------------------- */
import AdminLayout from '../../admin/components/AdminLayout.astro';
import { groups } from '../../admin/resources';
const user = Astro.locals.user;
if (user.role !== 'fenja') {
return Astro.redirect('/');
}
---
<AdminLayout title="Backstage preview" groups={groups}>
<article class="bs-preview">
<p class="eyebrow body-sm">Step 4 · shell only</p>
<h1 class="page-title heading-lg">Backstage</h1>
<p class="section-note body-md">
No resources are registered yet — the sidebar will populate in steps 810.
This route exists so the layout, topbar, and sidebar tokens can be
reviewed in isolation.
</p>
</article>
</AdminLayout>
<style>
.bs-preview {
max-width: 44rem;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
</style>