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

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