Skip to content

g1t/apps/sudo/app/components/shell.tsx

305 lines11,298 bytesCodeBlame
1/**
2 * sudo's frame. On wide screens, a sidebar: Overview and Reach out as
3 * links, then sections that fold open to their pages, as GitLab and
4 * Vercel do theirs (apps/web's project sidebar is the same pattern). On a
5 * phone, a top bar whose menu holds the same. sudo ships no JavaScript,
6 * so every fold is a <details>, drawn open on the server for the section
7 * holding the current page. All of it comes from lib/nav.ts.
8 */
9import {
10 Activity,
11 Bot,
12 Building2,
13 Boxes,
14 ChevronRight,
15 CircleDollarSign,
16 Clock,
17 Cloud,
18 CreditCard,
19 Eye,
20 FileText,
21 Gauge,
22 HandCoins,
23 HandHelping,
24 Inbox,
25 LayoutDashboard,
26 LifeBuoy,
27 type LucideIcon,
28 Megaphone,
29 Menu,
30 ScrollText,
31 Server,
32 ShieldAlert,
33 ShieldCheck,
34 Signpost,
35 Siren,
36 Tags,
37 Ticket,
38 TrendingUp,
39 UserCog,
40 Users,
41 UsersRound,
42 X,
43 Zap,
44} from "lucide-react";
45import { Link, useLocation } from "react-router";
46
47import { Logo } from "~/components/logo";
48import { useZone } from "~/components/ui";
49import { NAV, type NavCounts, type NavGroup, type NavIcon, type NavItem, countFor, holdsCurrent, isCurrent } from "~/lib/nav";
50import { zoneAbbr } from "~/lib/time";
51
52const ICONS: Record<NavIcon, LucideIcon> = {
53 overview: LayoutDashboard,
54 workspaces: Boxes,
55 enterprises: Building2,
56 "reach-out": Zap,
57 people: Users,
58 invoices: FileText,
59 prices: Tags,
60 credits: HandCoins,
61 usage: Gauge,
62 stripe: CreditCard,
63 costs: Cloud,
64 agents: Bot,
65 abuse: ShieldAlert,
66 incidents: Siren,
67 inbox: Inbox,
68 "view-as": Eye,
69 announcements: Megaphone,
70 staff: UserCog,
71 audit: ScrollText,
72 requests: HandHelping,
73 overages: TrendingUp,
74 velocity: Activity,
75 invites: Ticket,
76 aliases: Signpost,
77 customers: UsersRound,
78 spend: Gauge,
79 revenue: CircleDollarSign,
80 platform: Server,
81 support: LifeBuoy,
82 team: UserCog,
83};
84
85/** A page's or section's icon, as the sidebar shows it. */
86export function NavGlyph({ icon, size = 15, className }: { icon: NavIcon; size?: number; className?: string }) {
87 const Icon = ICONS[icon];
88 return <Icon size={size} className={className} />;
89}
90
91/** How many are waiting on a page, such as access requests; nothing at zero. */
92function CountPill({ count, label }: { count: number; label: string }) {
93 if (count <= 0) return null;
94 return (
95 <span
96 title={label}
97 className="shrink-0 rounded-full bg-accent/15 px-1.5 py-px text-[0.6875rem] font-semibold text-accent tabular-nums ring-1 ring-accent/30"
98 >
99 {count > 99 ? "99+" : count}
100 <span className="sr-only"> {label}</span>
101 </span>
102 );
103}
104
105const COUNT_LABEL = "waiting";
106
107/** What an item's number counts, for screen readers: incidents are open, not waiting. */
108function countLabel(item: NavItem): string {
109 return item.count === "incidents" ? "open" : COUNT_LABEL;
110}
111
112function SoonPill() {
113 return (
114 <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
115 Soon
116 </span>
117 );
118}
119
120/** A top-level link, with its icon. */
121function TopLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) {
122 const current = isCurrent(item, pathname);
123 return (
124 <Link
125 to={item.to}
126 title={item.about}
127 aria-current={current ? "page" : undefined}
128 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
129 current ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
130 }`}
131 >
132 <NavGlyph icon={item.icon} className={`shrink-0 ${current ? "text-accent" : "text-faint group-hover:text-muted"}`} />
133 <span className="min-w-0 grow truncate">{item.label}</span>
134 <CountPill count={countFor([item], counts)} label={countLabel(item)} />
135 {item.soon && <SoonPill />}
136 </Link>
137 );
138}
139
140/** A page inside a section: indented, no icon, the current one marked in lavender. */
141function SubLink({ item, pathname, counts }: { item: NavItem; pathname: string; counts: NavCounts }) {
142 const current = isCurrent(item, pathname);
143 return (
144 <Link
145 to={item.to}
146 title={item.about}
147 aria-current={current ? "page" : undefined}
148 className={`relative flex h-8 items-center gap-2 rounded-md pr-2 pl-[2.375rem] text-[0.8125rem] transition-colors ${
149 current
150 ? "bg-raised font-medium text-fg before:absolute before:top-1.5 before:bottom-1.5 before:left-[1.1875rem] before:z-10 before:w-px before:bg-accent"
151 : item.soon
152 ? "text-faint hover:bg-raised/60 hover:text-muted"
153 : "text-muted hover:bg-raised/60 hover:text-fg"
154 }`}
155 >
156 <span className="min-w-0 grow truncate">{item.label}</span>
157 <CountPill count={countFor([item], counts)} label={countLabel(item)} />
158 {item.soon && <SoonPill />}
159 </Link>
160 );
161}
162
163/** A section: one row that folds open to its pages. Open when it holds the current page. */
164function Section({ group, pathname, counts }: { group: NavGroup & { title: string }; pathname: string; counts: NavCounts }) {
165 const current = holdsCurrent(group, pathname);
166 const waiting = countFor(group.items, counts);
167 const allSoon = group.items.every((item) => item.soon);
168 return (
169 <details open={current} className="group/section">
170 <summary
171 className={`group flex h-8 cursor-pointer list-none items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors select-none hover:bg-raised/60 [&::-webkit-details-marker]:hidden ${
172 current ? "text-fg" : allSoon ? "text-faint hover:text-muted" : "text-muted hover:text-fg"
173 }`}
174 >
175 {group.icon && <NavGlyph icon={group.icon} className={`shrink-0 ${current ? "text-muted" : "text-faint group-hover:text-muted"}`} />}
176 <span className={`min-w-0 grow truncate ${current ? "font-medium" : ""}`}>{group.title}</span>
177 {allSoon && (
178 <span className="group-open/section:hidden">
179 <SoonPill />
180 </span>
181 )}
182 {/* Folded, the section carries its pages' counts; open, the pages do. */}
183 {waiting > 0 && (
184 <span className="group-open/section:hidden">
185 <CountPill count={waiting} label={COUNT_LABEL} />
186 </span>
187 )}
188 <ChevronRight size={14} aria-hidden="true" className="shrink-0 text-faint transition-transform duration-150 group-open/section:rotate-90" />
189 </summary>
190 <ul className="relative mt-px mb-1 space-y-px before:absolute before:top-1 before:bottom-1 before:left-[1.1875rem] before:w-px before:bg-line">
191 {group.items.map((item) => (
192 <li key={item.to}>
193 <SubLink item={item} pathname={pathname} counts={counts} />
194 </li>
195 ))}
196 </ul>
197 </details>
198 );
199}
200
201function NavTree({ pathname, counts }: { pathname: string; counts: NavCounts }) {
202 return (
203 <div className="space-y-px">
204 {NAV.map((group, index) =>
205 group.title ? (
206 <Section key={group.title} group={group as NavGroup & { title: string }} pathname={pathname} counts={counts} />
207 ) : (
208 <ul key={index} className={`space-y-px ${index === 0 ? "" : "pt-2"} ${index < NAV.length - 1 ? "pb-2" : ""}`}>
209 {group.items.map((item) => (
210 <li key={item.to}>
211 <TopLink item={item} pathname={pathname} counts={counts} />
212 </li>
213 ))}
214 </ul>
215 ),
216 )}
217 </div>
218 );
219}
220
221/** Which zone the pages say times in, and the way to change it. */
222function TimesIn() {
223 const zone = useZone();
224 const { pathname, search } = useLocation();
225 const back = pathname === "/timezone" ? "/" : `${pathname}${search}`;
226 return (
227 <p className="mt-1.5 flex items-center gap-1.5 text-[0.6875rem] leading-snug text-faint">
228 <Clock size={12} aria-hidden="true" className="shrink-0" />
229 <span className="min-w-0 truncate" title={`Times are shown in ${zone}`}>
230 Times in {zone === "UTC" ? "UTC" : `${zone.split("/").at(-1)!.replace(/_/g, " ")} (${zoneAbbr(new Date(), zone)})`} ·{" "}
231 <Link to={`/timezone?back=${encodeURIComponent(back)}`} className="underline-offset-2 hover:text-fg hover:underline">
232 Change
233 </Link>
234 </span>
235 </p>
236 );
237}
238
239/** Who is signed in: a panel of its own at the foot of the menu. */
240function SignedIn({ email }: { email: string | null | undefined }) {
241 return (
242 <div className="border-t border-line bg-raised/40 px-4 py-3">
243 {email && (
244 <p title="Signed in through Cloudflare Access" className="flex items-center gap-2 text-[0.8125rem]">
245 <ShieldCheck size={15} className="shrink-0 text-accent" />
246 <span className="min-w-0 truncate font-mono text-xs text-fg-soft">{email}</span>
247 </p>
248 )}
249 <p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">g1t staff only. Every change is recorded with who made it.</p>
250 <TimesIn />
251 </div>
252 );
253}
254
255/** The sidebar, from `lg` up. */
256export function Sidebar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) {
257 const { pathname } = useLocation();
258 return (
259 <aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-line bg-surface/50 lg:flex">
260 <div className="flex h-14 shrink-0 items-center px-4">
261 <Link to="/" aria-label="sudo overview" className="flex">
262 <Logo />
263 </Link>
264 </div>
265 <nav aria-label="sudo" className="grow overflow-y-auto px-2 pt-2 pb-6">
266 <NavTree pathname={pathname} counts={counts} />
267 </nav>
268 <div className="shrink-0">
269 <SignedIn email={email} />
270 </div>
271 </aside>
272 );
273}
274
275/** The top bar and its menu, below `lg`. */
276export function MobileBar({ email, counts = {} }: { email: string | null | undefined; counts?: NavCounts }) {
277 const { pathname } = useLocation();
278 return (
279 <header className="sticky top-0 z-40 border-b border-line bg-surface/95 backdrop-blur lg:hidden">
280 <div className="flex h-14 items-center justify-between gap-2 px-4">
281 <Link to="/" aria-label="sudo overview" className="flex">
282 <Logo />
283 </Link>
284 {/* A details element: it opens and closes with no script. A link
285 loads a new page, which arrives with the menu closed. */}
286 <details className="group/menu">
287 <summary
288 aria-label="Menu"
289 className="flex h-9 cursor-pointer list-none items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted transition-colors select-none hover:border-line-strong hover:text-fg group-open/menu:border-line-strong group-open/menu:text-fg [&::-webkit-details-marker]:hidden"
290 >
291 <Menu size={16} className="group-open/menu:hidden" />
292 <X size={16} className="hidden group-open/menu:block" />
293 Menu
294 </summary>
295 <div className="absolute inset-x-0 top-14 max-h-[calc(100dvh-3.5rem)] overflow-y-auto border-b border-line bg-bg shadow-2xl shadow-black/50">
296 <nav aria-label="sudo" className="px-2 pt-3 pb-4">
297 <NavTree pathname={pathname} counts={counts} />
298 </nav>
299 <SignedIn email={email} />
300 </div>
301 </details>
302 </div>
303 </header>
304 );
305}