feat(admin): improve responsive dashboard and settings (#539)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user