From 6609ce8e50b9b2cdd79df968ea98ef1440e7d6df Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 11 Sep 2026 23:03:10 +0200 Subject: [PATCH] feat(site-scope): add the topbar scope switcher --- src/components/layout/sidebar.tsx | 14 ++ src/components/layout/site-scope-switcher.tsx | 199 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/components/layout/site-scope-switcher.tsx diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 215e8dd..adb9dae 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -20,6 +20,7 @@ import { Wrench, X, } from "lucide-react"; +import { SiteScopeSwitcher } from "@/components/layout/site-scope-switcher"; import { Logo } from "@/components/logo"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; @@ -286,8 +287,21 @@ export function Topbar({ title, subtitle, right }: { title: string; subtitle?: s

{title}

{subtitle &&

{subtitle}

} + {/* Mobile: the scope pill gets its own row. The hamburger is + position:fixed over the topbar's left edge (line 252), so + the two would otherwise compete for that space. */} +
+ + + +
+
+ + + +
{right}
diff --git a/src/components/layout/site-scope-switcher.tsx b/src/components/layout/site-scope-switcher.tsx new file mode 100644 index 0000000..6ee33fd --- /dev/null +++ b/src/components/layout/site-scope-switcher.tsx @@ -0,0 +1,199 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Building2, Check, ChevronDown, LayoutGrid } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useSiteScope } from "@/components/layout/site-scope-provider"; +import { + resolveActive, + SITE_SCOPE_ALL, + SITE_SCOPE_COOKIE, + SITE_SCOPE_PARAM, +} from "@/lib/site-scope"; +import { cn } from "@/lib/utils"; + +/** One year, in seconds. */ +const COOKIE_MAX_AGE = 31_536_000; + +function useScopeState() { + const { allowed, fallbackValue } = useSiteScope(); + const params = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + + // Same pure function the server pages call, with the same inputs, so + // the pill can never disagree with the data below it. + const { active, mode } = resolveActive( + allowed, + params.get(SITE_SCOPE_PARAM) ?? undefined, + fallbackValue + ); + + const label = + allowed.length === 0 + ? "No sites assigned" + : mode === "single" + ? (active[0]?.name ?? "No sites assigned") + : `All sites · ${allowed.length}`; + + const selected = mode === "single" ? (active[0]?.id ?? SITE_SCOPE_ALL) : SITE_SCOPE_ALL; + + const setScope = React.useCallback( + (value: string) => { + document.cookie = `${SITE_SCOPE_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`; + const next = new URLSearchParams(params.toString()); + next.set(SITE_SCOPE_PARAM, value); + // replace, not push: a scope change is not a navigation step, and + // filling the back stack with scope toggles makes Back unusable. + // pathname, not a fixed route: switching must keep you on the page + // you are on. + router.replace(`${pathname}?${next.toString()}`, { scroll: false }); + }, + [params, pathname, router] + ); + + return { allowed, label, selected, setScope, interactive: allowed.length > 1 }; +} + +function Pill({ + label, + interactive, + className, +}: { + label: string; + interactive: boolean; + className?: string; +}) { + return ( + + + {label} + {interactive && } + + ); +} + +function Options({ + allowed, + selected, + onPick, + Row, +}: { + allowed: ReturnType["allowed"]; + selected: string; + onPick: (value: string) => void; + Row: React.ComponentType<{ onSelect: () => void; children: React.ReactNode }>; +}) { + return ( + <> + {allowed.map((s) => ( + onPick(s.id)}> + {selected === s.id ? ( + + ) : ( + + )} + {s.name} + + ))} + onPick(SITE_SCOPE_ALL)}> + {selected === SITE_SCOPE_ALL ? ( + + ) : ( + + )} + All my sites + {allowed.length} + + + ); +} + +export function SiteScopeSwitcher({ variant }: { variant: "dropdown" | "sheet" }) { + const { allowed, label, selected, setScope, interactive } = useScopeState(); + const [open, setOpen] = React.useState(false); + + // One allowed site (or none): a static label. Nothing to tap — a + // dropdown with a single entry is noise. It still shows, because + // knowing which site a margin belongs to is the point. + if (!interactive) return ; + + if (variant === "dropdown") { + return ( + + + + + + Your sites + ( + + {children} + + )} + /> + + + + ); + } + + // Bottom sheet. Built from the Radix primitives rather than our + // DialogContent, whose `left-1/2 top-1/2 -translate-*` centring would + // have to be fought class-by-class to anchor at the bottom. + return ( + + + + + + + + + Which site? + +
+ { + setScope(value); + setOpen(false); + }} + Row={({ onSelect, children }) => ( + + )} + /> +
+
+
+
+ ); +}