Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | 4x 4x 4x 4x 1x 4x 225x 15x 15x 15x 15x 15x 225x 180x 15x 180x 15x 1x 180x 180x 180x 180x 15x 1x 1x 165x 15x 150x 2x 1x 1x | import { Link, useRouterState } from "@tanstack/react-router";
import { LogOut, Monitor, Moon, Sun } from "lucide-react";
import type React from "react";
import { Drawer } from "vaul";
import { useAuth } from "../../lib/auth-context";
import { type Theme, useTheme } from "../../lib/theme-context";
import { cn } from "../../lib/utils";
import {
BOTTOM_NAV_HREFS,
flattenNav,
type NavActionId,
type NavBadges,
type ResolvedEntry,
type ResolvedItem,
resolveActiveHref,
} from "./nav-config";
import { INDEX_MARK } from "./nav-rail";
const themeIcons: Record<Theme, typeof Sun> = {
light: Sun,
dark: Moon,
system: Monitor,
};
const themeLabels: Record<Theme, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
const themeCycle: Record<Theme, Theme> = {
light: "dark",
dark: "system",
system: "light",
};
/** Non-navigating rows. One place, so the sheet has no per-item branches. */
const ACTIONS: Record<NavActionId, () => void> = {
"open-feedback": () => window.dispatchEvent(new CustomEvent("open-feedback")),
};
// vaul 1.x PortalProps derives from ComponentPropsWithoutRef which drops
// children in React 19 — cast once here rather than suppressing at every site.
const DrawerPortal =
Drawer.Portal as React.ComponentType<React.PropsWithChildren>;
export type MoreSheetProps = {
open: boolean;
onClose: () => void;
/** The resolved pro nav; the four bottom slots are filtered out. */
entries: ResolvedEntry[];
badges?: NavBadges;
};
function isSlot(item: ResolvedItem): boolean {
return (
item.kind !== "action" &&
BOTTOM_NAV_HREFS.includes(item.href as (typeof BOTTOM_NAV_HREFS)[number])
);
}
/**
* More: everything the four fixed slots do not carry, read off the same nav
* config the rail renders. Adding a destination to the config puts it here
* too — which is the point of the config being data.
*/
export function MoreSheet({ open, onClose, entries, badges }: MoreSheetProps) {
const pathname = useRouterState().location.pathname;
const { signOut } = useAuth();
const { theme, setTheme } = useTheme();
const ThemeIcon = themeIcons[theme];
const activeHref = resolveActiveHref(entries, pathname);
const items = flattenNav(entries).filter((item) => !isSlot(item));
const tileClass = (active: boolean) =>
cn(
"relative flex min-h-11 items-center gap-2 rounded-sm px-3 py-2 text-sm transition-colors",
active
? cn(
"bg-foreground-default/7 font-semibold text-foreground-default",
INDEX_MARK,
)
: "text-foreground-muted hover:bg-foreground-default/7",
);
const count = (item: ResolvedItem) =>
item.kind === "link" && item.badge ? (badges?.[item.badge] ?? 0) : 0;
return (
<Drawer.Root
open={open}
onOpenChange={(next) => {
Eif (!next) onClose();
}}
>
<DrawerPortal>
<Drawer.Overlay className="fixed inset-0 z-[55] bg-overlay-light" />
<Drawer.Content className="fixed inset-x-0 bottom-0 z-[55] rounded-t-md bg-background-elevated pb-safe">
<div className="mx-auto mt-3 mb-2 h-1 w-10 flex-shrink-0 rounded-full bg-border-default" />
<Drawer.Title className="sr-only">More options</Drawer.Title>
<Drawer.Description className="sr-only">
Everything the four bottom slots do not carry.
</Drawer.Description>
<nav aria-label="More navigation" className="px-2 pb-4">
<ul className="grid grid-cols-2 gap-1">
{items.map((item) => {
const active = item.kind === "link" && item.href === activeHref;
const badge = count(item);
const label = (
<>
<span className="truncate">{item.name}</span>
{badge > 0 && (
<span className="ml-auto text-xs tabular-nums">
{badge > 99 ? "99+" : badge}
</span>
)}
</>
);
if (item.kind === "action") {
return (
<li key={item.action}>
<button
type="button"
onClick={() => {
onClose();
ACTIONS[item.action]();
}}
className={cn(tileClass(false), "w-full text-left")}
>
{label}
</button>
</li>
);
}
if (item.kind === "external") {
// Same tab on a phone (#372): tab switching is awkward
// there and the portal is reachable via Back.
return (
<li key={item.href}>
<a
href={item.href}
onClick={onClose}
className={tileClass(false)}
>
{label}
</a>
</li>
);
}
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
aria-current={active ? "page" : undefined}
className={tileClass(active)}
>
{label}
</Link>
</li>
);
})}
</ul>
<div className="mt-3 flex gap-1 border-t border-border-default px-1 pt-3">
<button
type="button"
onClick={() => setTheme(themeCycle[theme])}
className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-sm px-2 text-sm text-foreground-muted transition-colors hover:bg-foreground-default/7"
>
<ThemeIcon className="size-4" aria-hidden="true" />
{themeLabels[theme]}
</button>
<button
type="button"
onClick={() => {
onClose();
signOut();
}}
className="flex min-h-11 flex-1 items-center justify-center gap-2 rounded-sm px-2 text-sm text-error transition-colors hover:bg-error/10"
>
<LogOut className="size-4" aria-hidden="true" />
Logout
</button>
</div>
</nav>
</Drawer.Content>
</DrawerPortal>
</Drawer.Root>
);
}
|