feat: build housekeeping command deck shell
This commit is contained in:
1 parent
cfeb0f19b8
commit
addc9c7b1a
9 files changed
+528
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface HousekeepingPageShellProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
primaryAction?: ReactNode;
|
||||||
|
context?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HousekeepingPageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
context,
|
||||||
|
children,
|
||||||
|
}: HousekeepingPageShellProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{context ? <div>{context}</div> : null}
|
||||||
|
</div>
|
||||||
|
{primaryAction ? <div>{primaryAction}</div> : null}
|
||||||
|
</header>
|
||||||
|
<div>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HousekeepingPageShell } from "./housekeeping-page-shell";
|
||||||
|
import { HousekeepingPageState } from "./housekeeping-page-state";
|
||||||
|
|
||||||
|
describe("HousekeepingPageState", () => {
|
||||||
|
it.each([
|
||||||
|
["loading", "status"],
|
||||||
|
["empty", "status"],
|
||||||
|
["partial", "status"],
|
||||||
|
["error", "alert"],
|
||||||
|
] as const)("renders %s with the %s role", (state, role) => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HousekeepingPageState
|
||||||
|
state={state}
|
||||||
|
title={`${state} title`}
|
||||||
|
description={`${state} description`}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain(`role="${role}"`);
|
||||||
|
expect(html).toContain(`>${state} title<`);
|
||||||
|
expect(html).toContain(`>${state} description<`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces loading politely and makes the partial warning visible", () => {
|
||||||
|
const loading = renderToStaticMarkup(
|
||||||
|
<HousekeepingPageState
|
||||||
|
state="loading"
|
||||||
|
title="Loading title"
|
||||||
|
description="Loading description"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const partial = renderToStaticMarkup(
|
||||||
|
<HousekeepingPageState
|
||||||
|
state="partial"
|
||||||
|
title="Partial title"
|
||||||
|
description="Partial description"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loading).toContain('aria-live="polite"');
|
||||||
|
expect(partial).toContain('data-state-tone="warning"');
|
||||||
|
expect(partial).toContain(">Partial title<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders supplied retry content for an error without owning a callback", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HousekeepingPageState
|
||||||
|
state="error"
|
||||||
|
title="Could not load"
|
||||||
|
description="Try again later"
|
||||||
|
retryAction={<a href="/admin-next/operations">Retry preview</a>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('role="alert"');
|
||||||
|
expect(html).toContain('href="/admin-next/operations"');
|
||||||
|
expect(html).toContain(">Retry preview<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HousekeepingPageShell", () => {
|
||||||
|
it("renders supplied header, context, action, and body content", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<HousekeepingPageShell
|
||||||
|
title="People"
|
||||||
|
description="Review operator-facing people data"
|
||||||
|
context={<span>Preview context</span>}
|
||||||
|
primaryAction={<a href="/admin-next/people/new">Create preview</a>}
|
||||||
|
>
|
||||||
|
<p>Page body</p>
|
||||||
|
</HousekeepingPageShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("<header");
|
||||||
|
expect(html).toContain(">People<");
|
||||||
|
expect(html).toContain(">Review operator-facing people data<");
|
||||||
|
expect(html).toContain(">Preview context<");
|
||||||
|
expect(html).toContain('href="/admin-next/people/new"');
|
||||||
|
expect(html).toContain(">Create preview<");
|
||||||
|
expect(html).toContain(">Page body</p>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface HousekeepingPageStateProps {
|
||||||
|
state: "loading" | "empty" | "partial" | "error";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
retryAction?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HousekeepingPageState({
|
||||||
|
state,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
retryAction,
|
||||||
|
}: HousekeepingPageStateProps) {
|
||||||
|
const isError = state === "error";
|
||||||
|
const isPartial = state === "partial";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role={isError ? "alert" : "status"}
|
||||||
|
aria-live={state === "loading" ? "polite" : undefined}
|
||||||
|
className={`rounded-lg border p-4 ${
|
||||||
|
isError
|
||||||
|
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
|
||||||
|
: isPartial
|
||||||
|
? "border-[var(--admin-warning)] bg-[var(--admin-surface)]"
|
||||||
|
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPartial ? (
|
||||||
|
<p
|
||||||
|
data-state-tone="warning"
|
||||||
|
className="font-medium text-[var(--admin-warning)]"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{retryAction ? <div className="mt-3">{retryAction}</div> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
interface CommandTriggerProps {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandTrigger({ label }: CommandTriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface ContextNavProps {
|
||||||
|
items: readonly { id: string; href: string; label: string }[];
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={ariaLabel} className="min-w-0">
|
||||||
|
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="min-w-max">
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { FileText, Gem, Hotel, Inbox, Settings, Users } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||||
|
|
||||||
|
interface DomainRailProps {
|
||||||
|
domains: readonly HousekeepingNavigationDomain[];
|
||||||
|
activeDomainId: HousekeepingNavigationDomain["id"];
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainIcon({ iconId }: Pick<HousekeepingNavigationDomain, "iconId">) {
|
||||||
|
const Icon = {
|
||||||
|
inbox: Inbox,
|
||||||
|
users: Users,
|
||||||
|
"file-text": FileText,
|
||||||
|
gem: Gem,
|
||||||
|
hotel: Hotel,
|
||||||
|
settings: Settings,
|
||||||
|
}[iconId];
|
||||||
|
|
||||||
|
return <Icon aria-hidden="true" size={18} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainRail({
|
||||||
|
domains,
|
||||||
|
activeDomainId,
|
||||||
|
ariaLabel,
|
||||||
|
}: DomainRailProps) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={ariaLabel} className="w-full lg:w-20">
|
||||||
|
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<li key={domain.id} className="min-w-max lg:min-w-0">
|
||||||
|
<Link
|
||||||
|
href={domain.href}
|
||||||
|
aria-current={domain.id === activeDomainId ? "page" : undefined}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
|
||||||
|
>
|
||||||
|
<span data-domain-icon={domain.iconId}>
|
||||||
|
<DomainIcon iconId={domain.iconId} />
|
||||||
|
</span>
|
||||||
|
<span className="lg:sr-only">{domain.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||||
|
import { HousekeepingShell } from "./housekeeping-shell";
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
skipToContent: "Skip to housekeeping content",
|
||||||
|
primaryNavigation: "Primary housekeeping navigation",
|
||||||
|
contextualNavigation: "Operations navigation",
|
||||||
|
command: "Commands are unavailable in preview",
|
||||||
|
preview: "Preview",
|
||||||
|
backToSite: "Back to site",
|
||||||
|
};
|
||||||
|
|
||||||
|
const domains: readonly HousekeepingNavigationDomain[] = [
|
||||||
|
{
|
||||||
|
id: "operations",
|
||||||
|
href: "/admin-next/operations",
|
||||||
|
iconId: "inbox",
|
||||||
|
label: "Operations",
|
||||||
|
description: "Manage operations",
|
||||||
|
items: [
|
||||||
|
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "people",
|
||||||
|
href: "/admin-next/people",
|
||||||
|
iconId: "users",
|
||||||
|
label: "People",
|
||||||
|
description: "Manage people",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "content",
|
||||||
|
href: "/admin-next/content",
|
||||||
|
iconId: "file-text",
|
||||||
|
label: "Content",
|
||||||
|
description: "Manage content",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "economy",
|
||||||
|
href: "/admin-next/economy",
|
||||||
|
iconId: "gem",
|
||||||
|
label: "Economy",
|
||||||
|
description: "Manage economy",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hotel",
|
||||||
|
href: "/admin-next/hotel",
|
||||||
|
iconId: "hotel",
|
||||||
|
label: "Hotel",
|
||||||
|
description: "Manage hotel",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
href: "/admin-next/system",
|
||||||
|
iconId: "settings",
|
||||||
|
label: "System",
|
||||||
|
description: "Manage system",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderShell(
|
||||||
|
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
|
||||||
|
): string {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<HousekeepingShell
|
||||||
|
actor={{ id: 7, username: "Nora", rank: 6 }}
|
||||||
|
activeDomainId={activeDomainId}
|
||||||
|
domains={domains}
|
||||||
|
labels={labels}
|
||||||
|
>
|
||||||
|
<p>Deck body</p>
|
||||||
|
</HousekeepingShell>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HousekeepingShell", () => {
|
||||||
|
it("renders the accessible command deck landmarks and supplied shell content", () => {
|
||||||
|
const html = renderShell();
|
||||||
|
|
||||||
|
expect(html).toContain('href="#housekeeping-content"');
|
||||||
|
expect(html).toContain(">Skip to housekeeping content<");
|
||||||
|
expect(html).toContain("<aside");
|
||||||
|
expect(html).toContain("<header");
|
||||||
|
expect(html).toContain('<nav aria-label="Primary housekeeping navigation"');
|
||||||
|
expect(html).toContain('<nav aria-label="Operations navigation"');
|
||||||
|
expect(html).toContain('<main id="housekeeping-content"');
|
||||||
|
expect(html).toContain(">Deck body</p>");
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<a(?=[^>]*href="\/admin-next\/operations")(?=[^>]*aria-current="page")/,
|
||||||
|
);
|
||||||
|
expect(html).toContain('href="/admin-next/operations/queue"');
|
||||||
|
expect(html).toContain(">Queue<");
|
||||||
|
expect(html).toContain(">Nora<");
|
||||||
|
expect(html).toContain(">6<");
|
||||||
|
expect(html).toContain(">Preview<");
|
||||||
|
expect(html).toContain('href="/"');
|
||||||
|
expect(html).toContain(">Back to site<");
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps every authorized domain icon and only marks the active domain", () => {
|
||||||
|
const html = renderShell("people");
|
||||||
|
|
||||||
|
for (const iconId of [
|
||||||
|
"inbox",
|
||||||
|
"users",
|
||||||
|
"file-text",
|
||||||
|
"gem",
|
||||||
|
"hotel",
|
||||||
|
"settings",
|
||||||
|
]) {
|
||||||
|
expect(html).toContain(`data-domain-icon="${iconId}"`);
|
||||||
|
}
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<a(?=[^>]*href="\/admin-next\/people")(?=[^>]*aria-current="page")/,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain(
|
||||||
|
'href="/admin-next/operations" aria-current="page"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no contextual items when the active domain is absent", () => {
|
||||||
|
const html = renderShell("system");
|
||||||
|
|
||||||
|
expect(html).not.toContain('href="/admin-next/operations/queue"');
|
||||||
|
expect(html).not.toContain(">Queue<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Task 10 modules within the server-rendered source boundary", () => {
|
||||||
|
const taskFiles = [
|
||||||
|
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
|
||||||
|
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
|
||||||
|
"src/features/housekeeping/foundation/shell/context-nav.tsx",
|
||||||
|
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
|
||||||
|
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
|
||||||
|
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
|
||||||
|
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
|
||||||
|
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
|
||||||
|
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
|
||||||
|
];
|
||||||
|
const prohibited = [
|
||||||
|
new RegExp("use " + "client"),
|
||||||
|
new RegExp("cmd" + "k", "i"),
|
||||||
|
new RegExp("addEvent" + "Listener"),
|
||||||
|
new RegExp(
|
||||||
|
"from\\s+['\"][^'\"]*(?:@/lib/" +
|
||||||
|
"db|drizzle|/" +
|
||||||
|
"actions)[^'\"]*['\"]",
|
||||||
|
),
|
||||||
|
new RegExp("next" + "-intl"),
|
||||||
|
/(?:bg|text|border|from|to)-(?:red|blue|green|yellow|orange|purple|pink|gray|slate|zinc|neutral)-\d{2,3}/,
|
||||||
|
new RegExp("#" + "[0-9a-fA-F]{3,8}"),
|
||||||
|
new RegExp("(?:r" + "gb|h" + "sl)\\("),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of taskFiles) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
for (const pattern of prohibited) {
|
||||||
|
expect(source).not.toMatch(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { HousekeepingDomainId } from "../../migration/types";
|
||||||
|
import type { HousekeepingActor } from "../contracts";
|
||||||
|
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||||
|
import { CommandTrigger } from "./command-trigger";
|
||||||
|
import { ContextNav } from "./context-nav";
|
||||||
|
import { DomainRail } from "./domain-rail";
|
||||||
|
import { OperatorSummary } from "./operator-summary";
|
||||||
|
|
||||||
|
interface HousekeepingShellProps {
|
||||||
|
actor: HousekeepingActor;
|
||||||
|
activeDomainId: HousekeepingDomainId;
|
||||||
|
domains: readonly HousekeepingNavigationDomain[];
|
||||||
|
labels: {
|
||||||
|
skipToContent: string;
|
||||||
|
primaryNavigation: string;
|
||||||
|
contextualNavigation: string;
|
||||||
|
command: string;
|
||||||
|
preview: string;
|
||||||
|
backToSite: string;
|
||||||
|
};
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HousekeepingShell({
|
||||||
|
actor,
|
||||||
|
activeDomainId,
|
||||||
|
domains,
|
||||||
|
labels,
|
||||||
|
children,
|
||||||
|
}: HousekeepingShellProps) {
|
||||||
|
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||||
|
<a
|
||||||
|
href="#housekeeping-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{labels.skipToContent}
|
||||||
|
</a>
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||||
|
{labels.preview}
|
||||||
|
</span>
|
||||||
|
<OperatorSummary actor={actor} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CommandTrigger label={labels.command} />
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
|
{labels.backToSite}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
|
||||||
|
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
|
||||||
|
<DomainRail
|
||||||
|
domains={domains}
|
||||||
|
activeDomainId={activeDomainId}
|
||||||
|
ariaLabel={labels.primaryNavigation}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
|
||||||
|
<ContextNav
|
||||||
|
items={activeDomain?.items ?? []}
|
||||||
|
ariaLabel={labels.contextualNavigation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { HousekeepingActor } from "../contracts";
|
||||||
|
|
||||||
|
interface OperatorSummaryProps {
|
||||||
|
actor: HousekeepingActor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperatorSummary({ actor }: OperatorSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 text-sm text-[var(--admin-text)]">
|
||||||
|
<div className="truncate font-medium">{actor.username}</div>
|
||||||
|
<div
|
||||||
|
data-operator-rank={actor.rank}
|
||||||
|
className="text-[var(--admin-text-muted)]"
|
||||||
|
>
|
||||||
|
{actor.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user