feat(web): flatten admin shell — horizontal tabs, drop redundant banner
The /admin layout had three nested concentric shells: the global Shell header + main nav, then a per-admin header banner with a Sword icon, then a left-aligned admin sub-nav (AdminSidebar) — two competing left-edge navigation columns plus a redundant 'Admin' label that the URL and active nav already conveyed. Replaces the side sub-nav with a horizontal tab strip across the top of the admin content (matches the discover-page tab pattern), drops the duplicate header banner, and removes the disabled Users/Library placeholder items so the tab strip only shows surfaces that actually ship today. The component is renamed AdminSidebar -> AdminTabs to match its new shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { LayoutGrid, Plug, ListChecks, ShieldX, Users, FolderTree } from 'lucide-svelte';
|
|
||||||
|
|
||||||
type Item = {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
icon: typeof LayoutGrid;
|
|
||||||
placeholder?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: Item[] = [
|
|
||||||
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
|
||||||
{ href: '/admin/integrations', label: 'Integrations', icon: Plug },
|
|
||||||
{ href: '/admin/requests', label: 'Requests', icon: ListChecks },
|
|
||||||
{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX },
|
|
||||||
{ href: '/admin/users', label: 'Users', icon: Users, placeholder: true },
|
|
||||||
{ href: '/admin/library', label: 'Library', icon: FolderTree, placeholder: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
|
||||||
if (href === '/admin') return page.url.pathname === '/admin';
|
|
||||||
return page.url.pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="w-[220px] shrink-0 border-r border-border bg-surface">
|
|
||||||
<nav aria-label="Admin sections">
|
|
||||||
<ul class="space-y-1 p-2">
|
|
||||||
{#each items as item (item.href)}
|
|
||||||
<li>
|
|
||||||
{#if item.placeholder}
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-text-muted opacity-60"
|
|
||||||
aria-disabled="true"
|
|
||||||
title="Coming soon"
|
|
||||||
>
|
|
||||||
<item.icon size={16} strokeWidth={1} />
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
||||||
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
|
||||||
item.href
|
|
||||||
)
|
|
||||||
? 'bg-accent-tint text-text-primary border-l-2 border-accent pl-[10px]'
|
|
||||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
|
||||||
>
|
|
||||||
<item.icon size={16} strokeWidth={1} />
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/svelte';
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
|
||||||
pageUrl: new URL('http://localhost/admin')
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$app/state', () => ({
|
|
||||||
page: { get url() { return state.pageUrl; } }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import AdminSidebar from './AdminSidebar.svelte';
|
|
||||||
|
|
||||||
describe('AdminSidebar', () => {
|
|
||||||
test('Overview link is active when on /admin', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
|
||||||
render(AdminSidebar);
|
|
||||||
const overview = screen.getByRole('link', { name: /overview/i });
|
|
||||||
expect(overview).toHaveAttribute('aria-current', 'page');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Integrations link is active when on /admin/integrations', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin/integrations');
|
|
||||||
render(AdminSidebar);
|
|
||||||
expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Requests link is active when on /admin/requests', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin/requests');
|
|
||||||
render(AdminSidebar);
|
|
||||||
expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Quarantine link is active when on /admin/quarantine', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin/quarantine');
|
|
||||||
render(AdminSidebar);
|
|
||||||
expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Quarantine renders as a real link', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
|
||||||
render(AdminSidebar);
|
|
||||||
const link = screen.getByRole('link', { name: /quarantine/i });
|
|
||||||
expect(link).toHaveAttribute('href', '/admin/quarantine');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('placeholder items render as non-links with aria-disabled', () => {
|
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
|
||||||
render(AdminSidebar);
|
|
||||||
// Users + Library are still placeholders today.
|
|
||||||
expect(screen.queryByRole('link', { name: /^users$/i })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link', { name: /^library$/i })).not.toBeInTheDocument();
|
|
||||||
const users = screen.getByText(/^users$/i);
|
|
||||||
expect(users.closest('[aria-disabled="true"]')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
type Item = { href: string; label: string };
|
||||||
|
|
||||||
|
// Users and Library are deferred until they have real routes.
|
||||||
|
// Adding them as disabled items clutters the tab strip without serving the
|
||||||
|
// operator — they'll be added back when each surface ships.
|
||||||
|
const items: Item[] = [
|
||||||
|
{ href: '/admin', label: 'Overview' },
|
||||||
|
{ href: '/admin/integrations', label: 'Integrations' },
|
||||||
|
{ href: '/admin/requests', label: 'Requests' },
|
||||||
|
{ href: '/admin/quarantine', label: 'Quarantine' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/admin') return page.url.pathname === '/admin';
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav aria-label="Admin sections" class="border-b border-border">
|
||||||
|
<ul class="flex gap-2">
|
||||||
|
{#each items as item (item.href)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||||
|
class="block border-b-2 px-3 py-2 text-sm transition-colors {isActive(item.href)
|
||||||
|
? 'border-accent text-text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
pageUrl: new URL('http://localhost/admin')
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$app/state', () => ({
|
||||||
|
page: { get url() { return state.pageUrl; } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminTabs from './AdminTabs.svelte';
|
||||||
|
|
||||||
|
describe('AdminTabs', () => {
|
||||||
|
test('Overview tab is current when on /admin', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
|
render(AdminTabs);
|
||||||
|
const overview = screen.getByRole('link', { name: /overview/i });
|
||||||
|
expect(overview).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Integrations tab is current when on /admin/integrations', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin/integrations');
|
||||||
|
render(AdminTabs);
|
||||||
|
expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
|
'page'
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Requests tab is current when on /admin/requests', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin/requests');
|
||||||
|
render(AdminTabs);
|
||||||
|
expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Quarantine tab is current when on /admin/quarantine', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin/quarantine');
|
||||||
|
render(AdminTabs);
|
||||||
|
expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
|
'page'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders exactly four tabs (Users + Library deferred until built)', () => {
|
||||||
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
|
render(AdminTabs);
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
expect(links.map((l) => l.textContent?.trim())).toEqual([
|
||||||
|
'Overview',
|
||||||
|
'Integrations',
|
||||||
|
'Requests',
|
||||||
|
'Quarantine'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sword } from 'lucide-svelte';
|
import AdminTabs from '$lib/components/AdminTabs.svelte';
|
||||||
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
|
||||||
|
|
||||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-background text-text-primary">
|
<div class="space-y-6">
|
||||||
<header class="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
<AdminTabs />
|
||||||
<Sword size={20} strokeWidth={1.5} class="text-action-destructive" />
|
{@render children?.()}
|
||||||
<h1 class="font-display text-xl font-medium">Admin</h1>
|
|
||||||
</header>
|
|
||||||
<div class="flex">
|
|
||||||
<AdminSidebar />
|
|
||||||
<main class="flex-1 p-6">
|
|
||||||
{@render children?.()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user