feat(site-scope): add the topbar scope switcher

This commit is contained in:
DEls 2026-09-11 23:03:10 +02:00
parent 63eb8ed931
commit 6609ce8e50
2 changed files with 213 additions and 0 deletions

View file

@ -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
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
{/* 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. */}
<div className="mt-2 lg:hidden">
<React.Suspense fallback={null}>
<SiteScopeSwitcher variant="sheet" />
</React.Suspense>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden lg:block">
<React.Suspense fallback={null}>
<SiteScopeSwitcher variant="dropdown" />
</React.Suspense>
</div>
{right}
<ThemeToggle />
</div>

View file

@ -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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground",
interactive && "transition-colors hover:bg-muted",
className
)}
>
<Building2 className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="max-w-[11rem] truncate">{label}</span>
{interactive && <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
</span>
);
}
function Options({
allowed,
selected,
onPick,
Row,
}: {
allowed: ReturnType<typeof useScopeState>["allowed"];
selected: string;
onPick: (value: string) => void;
Row: React.ComponentType<{ onSelect: () => void; children: React.ReactNode }>;
}) {
return (
<>
{allowed.map((s) => (
<Row key={s.id} onSelect={() => onPick(s.id)}>
{selected === s.id ? (
<Check className="h-4 w-4 shrink-0 text-primary" />
) : (
<span aria-hidden className="w-4 shrink-0" />
)}
<span className="truncate">{s.name}</span>
</Row>
))}
<Row onSelect={() => onPick(SITE_SCOPE_ALL)}>
{selected === SITE_SCOPE_ALL ? (
<Check className="h-4 w-4 shrink-0 text-primary" />
) : (
<LayoutGrid className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">All my sites</span>
<span className="ml-auto text-xs text-muted-foreground">{allowed.length}</span>
</Row>
</>
);
}
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 <Pill label={label} interactive={false} />;
if (variant === "dropdown") {
return (
<DropdownMenu>
<DropdownMenuTrigger aria-label={`Site scope: ${label}`}>
<Pill label={label} interactive />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Your sites</DropdownMenuLabel>
<Options
allowed={allowed}
selected={selected}
onPick={setScope}
Row={({ onSelect, children }) => (
<DropdownMenuItem onSelect={onSelect} className="flex items-center gap-2">
{children}
</DropdownMenuItem>
)}
/>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
);
}
// 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 (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger aria-label={`Site scope: ${label}`}>
<Pill label={label} interactive />
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<DialogPrimitive.Content
className="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-card pb-[max(1rem,env(safe-area-inset-bottom))] pt-3 shadow-xl"
>
<DialogPrimitive.Title className="px-4 pb-2 text-sm font-medium text-foreground">
Which site?
</DialogPrimitive.Title>
<div className="max-h-[60vh] overflow-y-auto">
<Options
allowed={allowed}
selected={selected}
onPick={(value) => {
setScope(value);
setOpen(false);
}}
Row={({ onSelect, children }) => (
<button
type="button"
onClick={onSelect}
className="flex w-full items-center gap-2.5 border-t border-border px-4 py-3 text-left text-sm text-foreground transition-colors active:bg-muted"
>
{children}
</button>
)}
/>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}