1import { Link, useLocation } from "@tanstack/react-router";
2import { useId, useState } from "react";
3
4import { APP_BASE_NAME, APP_VERSION } from "../../branding";
5import { useDesktopUpdateState } from "../../state/desktopUpdate";
6import { KnifeMark } from "../KnifeMark";
7import { useEnvironmentStageLabel } from "../SidebarStageBackdrop";
8import { getSettingsNavigationItems } from "../settings/settingsNavigation";
9import { validateSettingsScopeSearch } from "../settings/settingsScope";
10import { Button } from "../ui/button";
11import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
12import { Separator } from "../ui/separator";
13import { useSidebar } from "../ui/sidebar";
14import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
15import { AppMenuUpdates } from "./SidebarUpdatePill";
16
17export function AppMenu() {
18 const [open, setOpen] = useState(false);
19 const settingsHeadingId = useId();
20 const { isMobile, setOpenMobile } = useSidebar();
21 const { pathname, search } = useLocation();
22 const scopeSearch = validateSettingsScopeSearch(pathname.startsWith("/settings") ? search : {});
23 const stageLabel = useEnvironmentStageLabel();
24 const updateState = useDesktopUpdateState();
25 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
26 const closeForNavigation = () => {
27 setOpen(false);
28 if (isMobile) setOpenMobile(false);
29 };
30
31 return (
32 <Popover open={open} onOpenChange={setOpen}>
33 <PopoverTrigger
34 render={
35 <Button variant="ghost" size="icon-sm" aria-label={`${APP_BASE_NAME} menu`}>
36 <KnifeMark className="size-4" />
37 </Button>
38 }
39 />
40 <PopoverPopup align="start" width="lg" padding="compact" keepMounted inert={!open}>
41 <div className="flex items-baseline justify-between gap-3 px-1.5 pt-1 pb-2">
42 <PopoverTitle>{APP_BASE_NAME}</PopoverTitle>
43 <span className="text-2xs text-muted-foreground">
44 {version} <span className="ml-1">{stageLabel}</span>
45 </span>
46 </div>
47 <AppMenuUpdates />
48 <AppMenuProviderUpdates onNavigate={closeForNavigation} />
49 <Separator className="my-2" />
50 <h3
51 id={settingsHeadingId}
52 className="flex min-h-7 items-center px-1.5 text-xs font-medium text-muted-foreground"
53 >
54 Settings
55 </h3>
56 <nav aria-labelledby={settingsHeadingId} className="grid grid-cols-2 gap-x-2">
57 {getSettingsNavigationItems(scopeSearch).map(({ to, label, icon: Icon }) => (
58 <Link
59 key={to}
60 to={to}
61 search={scopeSearch}
62 onClick={closeForNavigation}
63 aria-current={
64 pathname === to ||
65 pathname.startsWith(`${to}/`) ||
66 (to === "/settings/general" && pathname === "/settings/open-source-licenses")
67 ? "page"
68 : undefined
69 }
70 className="flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-xs text-foreground outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=page]:bg-accent"
71 >
72 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
73 {label}
74 </Link>
75 ))}
76 </nav>
77 </PopoverPopup>
78 </Popover>
79 );
80}