feat(admin): improve responsive dashboard and settings (#539)

This commit is contained in:
Quick
2026-08-04 11:43:50 -04:00
committed by GitHub
parent 567cdd1a15
commit 8bde6f1156
14 changed files with 401 additions and 99 deletions
+35
View File
@@ -1805,10 +1805,45 @@
.admin-shell {
width: 100%;
min-width: 0;
max-width: 1560px;
margin-inline: auto;
}
/* AdminLayout owns the outer gutter. Older admin pages still use page-shell
for their max-width, so remove its second horizontal gutter when nested. */
.admin-shell > .page-shell,
.admin-shell > .page-shell-wide {
padding-inline: 0;
}
.admin-dashboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr));
gap: 0.875rem;
}
@media (max-width: 639px) {
.admin-shell .page-header {
align-items: stretch;
}
.admin-shell .page-header > * {
width: 100%;
min-width: 0;
}
.admin-shell .page-header > :not(:first-child) {
flex-wrap: wrap;
justify-content: flex-start;
text-align: left;
}
.admin-shell .page-header [data-slot="button"] {
min-height: 2.75rem;
}
}
/* ── Section Header ──────────────────────────────────────── */
/* Consistent section heading with optional icon.
Usage: <div class="section-header">
+83 -31
View File
@@ -1,22 +1,46 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router";
import AdminSidebar from "@/components/AdminSidebar";
import ServerActivity from "@/components/ServerActivity";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { resolveAdminDocumentTitle } from "@/lib/documentTitle";
import { Menu } from "lucide-react";
import { Menu, X } from "lucide-react";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
const ADMIN_DESKTOP_MEDIA_QUERY = "(min-width: 64rem)";
export default function AdminLayout() {
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const { isBackgroundBarVisible } = useWatchPlaybackController();
const audiobookPlayback = useAudiobookPlaybackController();
const hasBackgroundBar = isBackgroundBarVisible || audiobookPlayback?.isBackgroundBarVisible;
const documentTitle = resolveAdminDocumentTitle(location.pathname);
const mobileTitle =
documentTitle === "Admin" ? "Dashboard" : documentTitle.replace(/^Admin /, "");
useDocumentTitle(resolveAdminDocumentTitle(location.pathname));
useDocumentTitle(documentTitle);
useEffect(() => {
const desktopMedia = window.matchMedia(ADMIN_DESKTOP_MEDIA_QUERY);
const closeMobileNavigation = (event: MediaQueryListEvent) => {
if (event.matches) {
setMobileOpen(false);
}
};
desktopMedia.addEventListener("change", closeMobileNavigation);
return () => desktopMedia.removeEventListener("change", closeMobileNavigation);
}, []);
return (
<div className="bg-background relative min-h-[100dvh] overflow-x-hidden">
@@ -32,44 +56,72 @@ export default function AdminLayout() {
<AdminSidebar />
</div>
{/* Mobile header */}
<div className="glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 lg:hidden">
<div className="flex items-center gap-3">
<button
onClick={() => setMobileOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all"
aria-label="Open admin menu"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="text-primary border-border/70 bg-surface flex h-9 w-9 items-center justify-center rounded-xl border text-sm font-bold">
▶
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
{/* Mobile header */}
<div className="glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-3 py-2.5 lg:hidden">
<div className="flex min-w-0 items-center gap-2.5">
<SheetTrigger asChild>
<button
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 focus-visible:ring-ring/60 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl transition-all focus-visible:ring-[3px] focus-visible:outline-none"
aria-label="Open admin navigation"
>
<Menu className="h-5 w-5" />
</button>
</SheetTrigger>
<div className="flex min-w-0 items-center gap-2">
<div className="text-primary border-border/70 bg-surface flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border text-sm font-bold">
▶
</div>
<div className="min-w-0">
<span className="text-muted-foreground block text-[10px] leading-none font-semibold tracking-[0.16em] uppercase">
Admin
</span>
<span className="mt-1 block truncate text-[15px] leading-none font-extrabold tracking-tight">
{mobileTitle}
</span>
</div>
</div>
<span className="text-[15px] font-extrabold tracking-tight">Admin</span>
</div>
<ServerActivity className="h-11 w-11" />
</div>
<ServerActivity />
</div>
{/* Mobile sidebar drawer */}
<SheetContent
side="left"
showCloseButton={false}
onCloseAutoFocus={(event) => {
if (window.matchMedia(ADMIN_DESKTOP_MEDIA_QUERY).matches) {
event.preventDefault();
document.getElementById("main-content")?.focus({ preventScroll: true });
}
}}
className="w-[320px] max-w-[calc(100vw-3rem)] gap-0 border-r-0 p-0 sm:max-w-[320px]"
>
<SheetHeader className="sr-only">
<SheetTitle>Admin Navigation</SheetTitle>
</SheetHeader>
<SheetClose asChild>
<button
type="button"
aria-label="Close admin navigation"
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/60 absolute top-4 right-4 z-10 flex h-11 w-11 items-center justify-center rounded-xl transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</SheetClose>
<AdminSidebar embedded onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
{/* Desktop activity indicator */}
<div className="fixed top-5 right-5 z-40 hidden lg:block">
<ServerActivity />
</div>
{/* Mobile sidebar drawer */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-[260px] p-0 sm:max-w-[260px]">
<SheetHeader className="sr-only">
<SheetTitle>Admin Navigation</SheetTitle>
</SheetHeader>
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
<main
id="main-content"
className={`relative z-10 min-h-screen px-4 py-4 sm:px-6 lg:ml-[240px] lg:px-8 lg:py-8 xl:px-10 ${
tabIndex={-1}
className={`relative z-10 min-h-screen min-w-0 px-4 py-4 sm:px-6 lg:ml-[240px] lg:px-8 lg:py-8 xl:px-10 ${
hasBackgroundBar ? "pb-32 sm:pb-36" : ""
}`}
>
+10 -2
View File
@@ -57,10 +57,10 @@ vi.mock("@/hooks/queries/admin/policy", () => ({
usePolicyCapability: () => mockUsePolicyCapability(),
}));
function renderSidebar() {
function renderSidebar(embedded = false) {
return renderToStaticMarkup(
<MemoryRouter initialEntries={["/admin"]}>
<AdminSidebar />
<AdminSidebar embedded={embedded} />
</MemoryRouter>,
);
}
@@ -85,6 +85,14 @@ describe("AdminSidebar", () => {
}
});
it("renders as an embedded rail inside the mobile drawer", () => {
const markup = renderSidebar(true);
expect(markup).toContain('data-layout="drawer"');
expect(markup).toContain("relative h-full w-full");
expect(markup).not.toContain("fixed top-0 bottom-0 left-0");
});
it("includes a Sections link in the content navigation", () => {
const markup = renderSidebar();
+12 -2
View File
@@ -14,6 +14,7 @@ import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins";
import { usePolicyCapability } from "@/hooks/queries/admin/policy";
import { useAdminSessions } from "@/hooks/queries/admin/stats";
import { useBuildInfo } from "@/hooks/queries/admin/system";
import { cn } from "@/lib/utils";
interface SidebarItem extends AdminNavItem {
badge?: ReactNode;
@@ -26,6 +27,7 @@ interface SidebarSection extends Omit<AdminNavGroup, "items"> {
interface AdminSidebarProps {
onNavigate?: () => void;
embedded?: boolean;
}
function useSessionCount() {
@@ -33,7 +35,7 @@ function useSessionCount() {
return sessions.length;
}
export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
export default function AdminSidebar({ onNavigate, embedded = false }: AdminSidebarProps) {
const location = useLocation();
const sessionCount = useSessionCount();
const buildInfo = useBuildInfo();
@@ -78,7 +80,15 @@ export default function AdminSidebar({ onNavigate }: AdminSidebarProps) {
}
return (
<aside className="border-sidebar-border/70 bg-sidebar/92 fixed top-0 bottom-0 left-0 z-40 flex w-[240px] flex-col border-r backdrop-blur-2xl">
<aside
data-layout={embedded ? "drawer" : "desktop"}
className={cn(
"border-sidebar-border/70 bg-sidebar/92 flex w-[240px] flex-col border-r backdrop-blur-2xl",
embedded
? "relative h-full w-full border-r-0 [&_nav_a]:min-h-11 [&_nav_button]:min-h-11"
: "fixed top-0 bottom-0 left-0 z-40",
)}
>
{/* Logo */}
<div className="flex items-center gap-2.5 px-5 pt-6 pb-4">
<Link
+7 -2
View File
@@ -16,6 +16,7 @@ import {
classifyActivityMethod,
compareActivityMethods,
} from "@/pages/adminActivityPresentation";
import { cn } from "@/lib/utils";
const CONNECTION_PROBLEM_INDICATOR_DELAY_MS = 4_000;
const MAX_ACTIVITY_SCAN_ROWS = 25;
@@ -25,6 +26,7 @@ const ACTIVE_SCAN_SNAPSHOT_LIMIT = 500;
interface ServerActivityProps {
/** Hide the trigger button entirely when there is no activity */
hideWhenEmpty?: boolean;
className?: string;
}
// ── Data hook ────────────────────────────────────────────────
@@ -99,7 +101,7 @@ function useDelayedConnectionProblem(connectionState: RealtimeConnectionState) {
return isNonLive && connectionProblemState;
}
export default function ServerActivity({ hideWhenEmpty = false }: ServerActivityProps) {
export default function ServerActivity({ hideWhenEmpty = false, className }: ServerActivityProps) {
const [open, setOpen] = useState(false);
const location = useLocation();
@@ -132,7 +134,10 @@ export default function ServerActivity({ hideWhenEmpty = false }: ServerActivity
? `Server activity: ${activeScansMayBeTruncated ? "at least " : ""}${totalActive} active`
: "Server activity"
}
className="hover:bg-accent/60 relative flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
className={cn(
"hover:bg-accent/60 relative flex h-9 w-9 items-center justify-center rounded-xl transition-colors",
className,
)}
>
<Activity
className={`h-[18px] w-[18px] ${
@@ -1,7 +1,10 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Link } from "react-router";
import { cn } from "@/lib/utils";
export interface SettingsOverviewNavItem {
id: string;
label: string;
@@ -19,9 +22,28 @@ interface SettingsOverviewNavProps {
groups: readonly SettingsOverviewNavGroup[];
ariaLabel: string;
idPrefix: string;
variant?: "grouped-list" | "directory";
}
export function SettingsOverviewNav({ groups, ariaLabel, idPrefix }: SettingsOverviewNavProps) {
function groupSectionId(idPrefix: string, label: string) {
return `${idPrefix}-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
}
export function SettingsOverviewNav({
groups,
ariaLabel,
idPrefix,
variant = "grouped-list",
}: SettingsOverviewNavProps) {
const firstGroupId = groups[0] ? groupSectionId(idPrefix, groups[0].label) : "";
const [activeGroupId, setActiveGroupId] = useState(firstGroupId);
const isDirectory = variant === "directory";
const resolvedActiveGroupId = groups.some(
(group) => groupSectionId(idPrefix, group.label) === activeGroupId,
)
? activeGroupId
: firstGroupId;
if (groups.length === 0) {
return (
<div className="surface-panel-subtle rounded-2xl px-5 py-8 text-center">
@@ -34,49 +56,135 @@ export function SettingsOverviewNav({ groups, ariaLabel, idPrefix }: SettingsOve
}
return (
<nav aria-label={ariaLabel} className="grid items-start gap-6 lg:grid-cols-2">
{groups.map((group) => {
const sectionId = `${idPrefix}-${group.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
<div className={cn(isDirectory && "lg:space-y-8")}>
{isDirectory ? (
<nav
aria-label={`${ariaLabel} categories`}
className="surface-panel-subtle hidden max-w-2xl overflow-hidden rounded-xl border lg:flex"
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
const isActive = sectionId === resolvedActiveGroupId;
return (
<section key={group.label} aria-labelledby={sectionId}>
<h2
id={sectionId}
className="text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase"
return (
<a
key={group.label}
href={`#${sectionId}`}
aria-label={`${group.label}, ${group.items.length} settings sections`}
aria-current={isActive ? "location" : undefined}
onClick={() => setActiveGroupId(sectionId)}
className={cn(
"text-muted-foreground hover:bg-surface-hover/60 hover:text-foreground focus-visible:ring-ring/60 relative flex min-h-11 flex-1 items-center justify-center gap-2 px-4 text-sm font-medium transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:outline-none focus-visible:ring-inset",
isActive &&
"text-foreground after:bg-primary after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-full",
)}
>
<span>{group.label}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{group.items.length}
</span>
</a>
);
})}
</nav>
) : null}
<nav
aria-label={ariaLabel}
className={cn(
"grid items-start gap-6 lg:grid-cols-2",
isDirectory && "block space-y-6 lg:space-y-10",
)}
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
return (
<section
key={group.label}
aria-labelledby={sectionId}
className={cn(isDirectory && "scroll-mt-6")}
>
{group.label}
</h2>
<ul className="surface-panel overflow-hidden rounded-2xl border-0 shadow-none">
{group.items.map((item) => {
const Icon = item.icon;
<h2
id={sectionId}
className={cn(
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
isDirectory &&
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
)}
>
{group.label}
{isDirectory ? (
<span className="text-muted-foreground ml-1.5 hidden font-medium lg:inline">
({group.items.length})
</span>
) : null}
</h2>
<ul
className={cn(
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
isDirectory &&
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
)}
>
{group.items.map((item) => {
const Icon = item.icon;
return (
<li key={item.id} className="border-border/60 border-b last:border-b-0">
<Link
to={item.href}
className="hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
return (
<li
key={item.id}
className={cn(
"border-border/60 border-b last:border-b-0",
isDirectory && "lg:border-0",
)}
>
<span className="bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{item.label}</span>
<span className="text-muted-foreground mt-0.5 block text-xs leading-snug">
{item.description}
<Link
to={item.href}
className={cn(
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isDirectory &&
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
)}
>
<span
className={cn(
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
isDirectory && "lg:h-10 lg:w-10",
)}
>
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
</span>
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-semibold",
isDirectory && "lg:text-[15px]",
)}
>
{item.label}
</span>
<span
className={cn(
"text-muted-foreground mt-0.5 block text-xs leading-snug",
isDirectory && "lg:line-clamp-3",
)}
>
{item.description}
</span>
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
</div>
);
}
@@ -14,6 +14,7 @@ interface SettingsSearchInputProps {
emptyLabel?: string;
className?: string;
shortcutMediaQuery?: string;
showShortcutHint?: boolean;
}
export function SettingsSearchInput({
@@ -26,10 +27,15 @@ export function SettingsSearchInput({
emptyLabel = "No matching settings",
className,
shortcutMediaQuery,
showShortcutHint = false,
}: SettingsSearchInputProps) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const hasQuery = value.trim().length > 0;
const shortcutHint =
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
? "⌘ K"
: "Ctrl K";
const status = hasQuery
? resultCount === 0
? emptyLabel
@@ -74,7 +80,7 @@ export function SettingsSearchInput({
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="h-10 rounded-xl pr-10 pl-9"
className={cn("h-11 rounded-xl pr-10 pl-9", showShortcutHint && !hasQuery && "sm:pr-16")}
autoComplete="off"
/>
{hasQuery ? (
@@ -82,10 +88,17 @@ export function SettingsSearchInput({
type="button"
aria-label="Clear settings search"
onClick={() => onChange("")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute inset-y-0 right-0 inline-flex w-11 items-center justify-center rounded-xl transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : showShortcutHint ? (
<kbd
aria-hidden="true"
className="border-border/80 bg-surface text-muted-foreground pointer-events-none absolute top-1/2 right-2 hidden h-6 -translate-y-1/2 items-center rounded-md border px-1.5 font-sans text-[10px] font-medium sm:inline-flex"
>
{shortcutHint}
</kbd>
) : null}
</div>
<p className="text-muted-foreground mt-2 text-xs" aria-live="polite">
+3
View File
@@ -34,8 +34,11 @@ describe("resolveSettingsDocumentTitle", () => {
describe("resolveAdminDocumentTitle", () => {
it("resolves major admin sections", () => {
expect(resolveAdminDocumentTitle("/admin")).toBe("Admin");
expect(resolveAdminDocumentTitle("/admin/access-groups")).toBe("Admin Access Groups");
expect(resolveAdminDocumentTitle("/admin/autoscan")).toBe("Admin Autoscan");
expect(resolveAdminDocumentTitle("/admin/collections")).toBe("Admin Collections");
expect(resolveAdminDocumentTitle("/admin/diagnostics")).toBe("Admin Client Diagnostics");
expect(resolveAdminDocumentTitle("/admin/policy")).toBe("Admin Policy");
expect(resolveAdminDocumentTitle("/admin/tasks/refresh-metadata")).toBe("Admin Task");
});
+3
View File
@@ -42,8 +42,10 @@ const SETTINGS_TITLES: Record<string, string> = {
};
const ADMIN_TITLES: Record<string, string> = {
"access-groups": "Admin Access Groups",
activity: "Admin Activity",
"api-keys": "Admin API Keys",
autoscan: "Admin Autoscan",
collections: "Admin Collections",
devices: "Admin Devices",
"settings/devices": "Your Devices",
@@ -56,6 +58,7 @@ const ADMIN_TITLES: Record<string, string> = {
maintenance: "Admin Maintenance",
nodes: "Admin Nodes",
plugins: "Admin Plugins",
policy: "Admin Policy",
recommendations: "Admin Recommendations",
requests: "Admin Requests",
sections: "Admin Sections",
+12 -10
View File
@@ -240,17 +240,17 @@ export default function AdminDashboard() {
Live sessions, content health, and server activity in one view.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center">
<div className="grid gap-2 sm:flex sm:items-center">
{lastUpdatedLabel && (
<span className="text-muted-foreground text-xs whitespace-nowrap">
<span className="text-muted-foreground text-xs sm:whitespace-nowrap">
Updated {lastUpdatedLabel}
</span>
)}
<Button
variant="outline"
size="sm"
className="min-w-[8.25rem] justify-center"
className="min-w-[8.25rem] justify-center sm:w-auto"
onClick={() => {
void refreshDashboard({ manual: true });
}}
@@ -266,7 +266,7 @@ export default function AdminDashboard() {
<Button
variant="default"
size="sm"
className="cursor-pointer"
className="w-full cursor-pointer sm:w-auto"
onClick={() => {
if (libraries.length > 0) {
scanAll.mutate();
@@ -333,7 +333,7 @@ function StatsRow({
return <SectionError message="Failed to load stats." />;
}
return (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
<div className="admin-dashboard-stats">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
@@ -380,7 +380,7 @@ function StatsRow({
];
return (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
<div className="admin-dashboard-stats">
{statCards.map((card) => (
<div
key={card.label}
@@ -851,7 +851,7 @@ function UsersCard({
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead className="hidden sm:table-cell">Role</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
@@ -872,11 +872,13 @@ function UsersCard({
</div>
<div>
<div className="text-[13px] font-semibold">{u.username}</div>
<div className="text-muted-foreground text-[10px]">{u.email}</div>
<div className="text-muted-foreground hidden text-[10px] sm:block">
{u.email}
</div>
</div>
</div>
</TableCell>
<TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant={u.role === "admin" ? "default" : "secondary"}>{u.role}</Badge>
</TableCell>
<TableCell>
+1 -1
View File
@@ -759,7 +759,7 @@ function ScanQueuePopover({
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[400px] p-0">
<PopoverContent align="end" className="w-[400px] max-w-[calc(100vw-1rem)] p-0">
{/* Accent bar */}
<div className="scan-queue-accent absolute inset-x-0 top-0 h-px rounded-t-xl" />
+12 -6
View File
@@ -135,12 +135,18 @@ export default function AdminRequests() {
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="gap-5">
<TabsList variant="line" className="border-border w-full justify-start border-b">
<TabsTrigger value="queue">Queue</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="integrations">Integrations</TabsTrigger>
<TabsTrigger value="overrides">User Overrides</TabsTrigger>
</TabsList>
<div className="-mx-4 overflow-x-auto px-4 pb-1 [scrollbar-width:thin] sm:mx-0 sm:px-0">
<TabsList
variant="line"
aria-label="Request administration sections"
className="border-border w-max min-w-full justify-start border-b"
>
<TabsTrigger value="queue">Queue</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="integrations">Integrations</TabsTrigger>
<TabsTrigger value="overrides">User Overrides</TabsTrigger>
</TabsList>
</div>
<TabsContent value="queue">
<RequestQueueTab />
</TabsContent>
@@ -69,6 +69,52 @@ describe("AdminSettingsLayout", () => {
}
});
it("renders desktop category jump links with section counts", () => {
renderInteractiveLayout();
const categoryNavigation = screen.getByRole("navigation", {
name: "Admin settings sections categories",
});
expect(categoryNavigation).toContainElement(
screen.getByRole("link", { name: "Server, 4 settings sections" }),
);
expect(screen.getByRole("link", { name: "Media, 7 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-media",
);
expect(screen.getByRole("link", { name: "Connections, 6 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-connections",
);
expect(screen.getByRole("link", { name: "Data, 3 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-data",
);
});
it("uses one desktop grid and card geometry for every settings group", () => {
const markup = renderLayout();
expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(4);
expect(markup).not.toContain("2xl:grid-cols-3");
expect(markup.match(/lg:h-28/g)).toHaveLength(20);
expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(20);
});
it("marks a selected category jump link as the current location", async () => {
renderInteractiveLayout();
const server = screen.getByRole("link", { name: "Server, 4 settings sections" });
const data = screen.getByRole("link", { name: "Data, 3 settings sections" });
expect(server).toHaveAttribute("aria-current", "location");
await userEvent.click(data);
expect(server).not.toHaveAttribute("aria-current");
expect(data).toHaveAttribute("aria-current", "location");
});
it("renders every settings tab", () => {
const markup = renderLayout();
@@ -192,6 +238,15 @@ describe("AdminSettingsLayout", () => {
expect(searchBox).toHaveFocus();
});
it("focuses admin settings search with Ctrl+K", () => {
renderInteractiveLayout();
const searchBox = screen.getByRole("searchbox", { name: "Search settings" });
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
expect(searchBox).toHaveFocus();
});
it("does not consume Cmd+K when the admin detail search is hidden", () => {
vi.stubGlobal(
"matchMedia",
@@ -136,7 +136,7 @@ export default function AdminSettingsLayout() {
}, [active]);
return (
<div className="space-y-6">
<div className="w-full max-w-[96rem] space-y-6">
{active ? (
<Link
to="/admin/settings"
@@ -160,8 +160,9 @@ export default function AdminSettingsLayout() {
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={SETTINGS_NAV.length}
className="w-full sm:max-w-sm"
className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none"
shortcutMediaQuery={active ? "(min-width: 64rem)" : undefined}
showShortcutHint={!active}
/>
</div>
@@ -222,11 +223,12 @@ export default function AdminSettingsLayout() {
</div>
</div>
) : (
<div className="mx-auto w-full max-w-6xl">
<div className="w-full">
<SettingsOverviewNav
groups={overviewGroups}
ariaLabel="Admin settings sections"
idPrefix="admin-settings-index"
variant="directory"
/>
</div>
)}