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:
parent
ea056fff7b
commit
dd7215d828
4 changed files with 410 additions and 0 deletions
|
|
@ -304,3 +304,238 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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>
|
||||||
15
src/admin/resources/index.ts
Normal file
15
src/admin/resources/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Resource registry — single source of truth for sidebar navigation.
|
||||||
|
*
|
||||||
|
* Groups are populated incrementally across steps 8–10 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: [] },
|
||||||
|
];
|
||||||
39
src/pages/admin/preview.astro
Normal file
39
src/pages/admin/preview.astro
Normal 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 8–10.
|
||||||
|
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>
|
||||||
Loading…
Add table
Reference in a new issue