diff --git a/web/src/app.css b/web/src/app.css index 5c11f19f..f9df08bb 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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:
diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index 6fead98d..9c749b81 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -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 (
@@ -32,44 +56,72 @@ export default function AdminLayout() {
- {/* Mobile header */} -
-
- -
-
- ▶ + + {/* Mobile header */} +
+
+ + + +
+
+ ▶ +
+
+ + Admin + + + {mobileTitle} + +
- Admin
+
- -
+ + {/* Mobile sidebar drawer */} + { + 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]" + > + + Admin Navigation + + + + + setMobileOpen(false)} /> + + {/* Desktop activity indicator */}
- {/* Mobile sidebar drawer */} - - - - Admin Navigation - - setMobileOpen(false)} /> - - -
diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index 3a6b380c..683b7d38 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -57,10 +57,10 @@ vi.mock("@/hooks/queries/admin/policy", () => ({ usePolicyCapability: () => mockUsePolicyCapability(), })); -function renderSidebar() { +function renderSidebar(embedded = false) { return renderToStaticMarkup( - + , ); } @@ -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(); diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index c7f83c69..3c9a8c48 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -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 { 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 ( -
-
-
+
+
{lastUpdatedLabel && ( - + Updated {lastUpdatedLabel} )} - + {/* Accent bar */}
diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index 91153af2..24c19a72 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -135,12 +135,18 @@ export default function AdminRequests() {
- - Queue - Settings - Integrations - User Overrides - +
+ + Queue + Settings + Integrations + User Overrides + +
diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx index a35c7417..26e59b9a 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.test.tsx @@ -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", diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index 9191229d..958cfb00 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -136,7 +136,7 @@ export default function AdminSettingsLayout() { }, [active]); return ( -
+
{active ? (
@@ -222,11 +223,12 @@ export default function AdminSettingsLayout() {
) : ( -
+
)}