From 714270b401d50d1f26d6b47905cdaa8f607472ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:58:04 +0000 Subject: [PATCH 01/56] feat(shell): monochrome rail, 240px panel and mobile top bar with bottom nav The rail drops the branded service logos for monochrome lucide icons, the PoliNetwork logo becomes the link to Overview (with an active ring), and Reports gets an accent dot while reports are open. The panel grows to 240px with 38px items and an accent indicator. Below 760px the panel sheet is replaced by a top bar (logo, service title, search, theme, account), the service's sections as scrolling pills and a bottom nav with the five services. Page headers now scroll with the page: PageBar renders the h1 with one line of context and the actions, then the toolbar row; deep pages show a back button and a breadcrumb. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016Wcgyqa8nfsrm4SSd283AF --- src/assets/svg/azure.svg | 1 - src/assets/svg/telegram.svg | 1 - src/assets/svg/whatsapp.svg | 1 - src/components/primitives/platform-glyph.tsx | 21 +- src/components/primitives/record-header.tsx | 27 +- src/components/route-error.tsx | 8 +- src/components/shell/command-palette.tsx | 3 +- src/components/shell/index.ts | 3 +- src/components/shell/mobile-nav.tsx | 128 +++++++ src/components/shell/nav.ts | 51 +-- src/components/shell/page-bar.tsx | 312 ++++++------------ src/components/shell/panel-sheet.tsx | 188 ----------- src/components/shell/panel.tsx | 87 +++-- src/components/shell/rail.tsx | 104 ++++-- src/components/shell/service-glyph.tsx | 11 - src/components/shell/shell.tsx | 130 +++----- src/features/dashboard/overview-page.tsx | 4 +- .../groups-by-label/category-page.tsx | 6 +- .../groups-by-label/tag-groups-page.tsx | 6 +- src/features/telegram/user-detail/profile.tsx | 14 +- src/styles.css | 5 + 21 files changed, 492 insertions(+), 619 deletions(-) delete mode 100644 src/assets/svg/azure.svg delete mode 100644 src/assets/svg/telegram.svg delete mode 100644 src/assets/svg/whatsapp.svg create mode 100644 src/components/shell/mobile-nav.tsx delete mode 100644 src/components/shell/panel-sheet.tsx delete mode 100644 src/components/shell/service-glyph.tsx diff --git a/src/assets/svg/azure.svg b/src/assets/svg/azure.svg deleted file mode 100644 index c71c4aa7..00000000 --- a/src/assets/svg/azure.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/svg/telegram.svg b/src/assets/svg/telegram.svg deleted file mode 100644 index 39f0e0da..00000000 --- a/src/assets/svg/telegram.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/svg/whatsapp.svg b/src/assets/svg/whatsapp.svg deleted file mode 100644 index 3a6f6c81..00000000 --- a/src/assets/svg/whatsapp.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/primitives/platform-glyph.tsx b/src/components/primitives/platform-glyph.tsx index eaad2768..a8a2c6b7 100644 --- a/src/components/primitives/platform-glyph.tsx +++ b/src/components/primitives/platform-glyph.tsx @@ -1,15 +1,22 @@ -import telegramLogo from "@/assets/svg/telegram.svg" -import whatsappLogo from "@/assets/svg/whatsapp.svg" +import { MessageCircle, Send } from "lucide-react" + import type { GroupWithLabels } from "@/lib/api/types" import { cn } from "@/lib/utils" const platforms = { - tg: { logo: telegramLogo, label: "Telegram" }, - wa: { logo: whatsappLogo, label: "WhatsApp" }, + tg: { icon: Send, label: "Telegram" }, + wa: { icon: MessageCircle, label: "WhatsApp" }, } -/** The 14px platform logo, named for assistive tech. */ +/** The platform as its monochrome service icon (the rail's), named for assistive tech. Size it with `className`. */ export function PlatformGlyph({ platform, className }: { platform: GroupWithLabels["type"]; className?: string }) { - const { logo, label } = platforms[platform] - return {label} + const { icon: Icon, label } = platforms[platform] + return ( + + ) } diff --git a/src/components/primitives/record-header.tsx b/src/components/primitives/record-header.tsx index 02ee7ddd..98e312d2 100644 --- a/src/components/primitives/record-header.tsx +++ b/src/components/primitives/record-header.tsx @@ -1,4 +1,4 @@ -import type { ReactNode, Ref } from "react" +import type { ReactNode } from "react" import { cn } from "@/lib/utils" @@ -13,36 +13,21 @@ type RecordHeaderProps = { description?: ReactNode chips?: ReactNode actions?: ReactNode - /** Observed by the shell's `ScrollTitle`. */ - titleRef?: Ref className?: string } /** The only `h1` on a deep page. */ -export function RecordHeader({ - title, - avatar, - dot, - meta, - description, - chips, - actions, - titleRef, - className, -}: RecordHeaderProps) { +export function RecordHeader({ title, avatar, dot, meta, description, chips, actions, className }: RecordHeaderProps) { return ( -
+
{avatar &&
{avatar}
}
-

+

{dot && } {title}

- {meta &&
{meta}
} - {description &&

{description}

} + {meta &&
{meta}
} + {description &&

{description}

} {chips &&
{chips}
}
{actions &&
{actions}
} diff --git a/src/components/route-error.tsx b/src/components/route-error.tsx index 58fc448c..e74b0ab6 100644 --- a/src/components/route-error.tsx +++ b/src/components/route-error.tsx @@ -13,7 +13,13 @@ import { Button } from "@/components/ui/button" function ErrorFrame({ children }: { children: ReactNode }) { const inShell = useInShell() const surface =
{children}
- if (inShell) return {surface} + if (inShell) { + return ( +
+ {surface} +
+ ) + } return (
{surface}
diff --git a/src/components/shell/command-palette.tsx b/src/components/shell/command-palette.tsx index 2da42452..cf2ea1ad 100644 --- a/src/components/shell/command-palette.tsx +++ b/src/components/shell/command-palette.tsx @@ -7,7 +7,6 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog" import { suppressNextFocusTooltip } from "@/components/ui/tooltip" -import { ServiceGlyph } from "./service-glyph" import { useTheme } from "./theme" import { useVisibleServices } from "./use-can" @@ -71,7 +70,7 @@ export function CommandPalette({ open, onOpenChange, onSignOut }: CommandPalette service.sections.map((section) => ({ id: `section:${service.id}:${section.id}`, label: `${service.title} › ${section.title}`, - glyph: , + glyph: , run: () => void navigate({ to: section.path }), })) ) diff --git a/src/components/shell/index.ts b/src/components/shell/index.ts index 912d9b47..406e0557 100644 --- a/src/components/shell/index.ts +++ b/src/components/shell/index.ts @@ -25,14 +25,13 @@ export { type PageBarBack, type PageBarProps, PageContent, - ScrollTitle, SearchField, type SearchFieldProps, Toolbar, type ToolbarProps, useInShell, + useShellFrame, } from "./page-bar" -export { ServiceGlyph } from "./service-glyph" export { DashboardShell, type DashboardShellProps } from "./shell" export { type Theme, themeToggleLabel, useTheme } from "./theme" export { appToast } from "./toast" diff --git a/src/components/shell/mobile-nav.tsx b/src/components/shell/mobile-nav.tsx new file mode 100644 index 00000000..3e0cc986 --- /dev/null +++ b/src/components/shell/mobile-nav.tsx @@ -0,0 +1,128 @@ +import { Link } from "@tanstack/react-router" +import { Moon, Search, Sun } from "lucide-react" +import { Suspense } from "react" + +import logoUrl from "@/assets/logo.png" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +import { AccountAvatar, type ShellUser } from "./account-avatar" +import { type DashboardPath, type PageMatch, type Service, serviceFor } from "./nav" +import { OpenReportsDot } from "./rail" +import { themeToggleLabel, useTheme } from "./theme" +import { useVisibleServices } from "./use-can" + +const barButton = "size-10 text-(--pn-fg-muted) hover:text-(--pn-fg) active:not-aria-[haspopup]:translate-y-0" + +export type MobileTopBarProps = { + match: PageMatch + onOpenPalette: () => void + user: ShellUser + className?: string +} + +/** + * Below 760px: the logo (Overview), the service title, search, theme and the account avatar. Overview shows the app + * name instead of a service. + */ +export function MobileTopBar({ match, onOpenPalette, user, className }: MobileTopBarProps) { + const service = serviceFor(match) + const { theme, toggleTheme } = useTheme() + const title = service.id === "overview" ? "PoliNetwork Admin" : service.title + + return ( +
+ + + + {title} + + + + + +
+ ) +} + +export type MobileBottomNavProps = { + match: PageMatch + serviceHref: (service: Service) => DashboardPath + pendingReports: Promise + className?: string +} + +/** Below 760px: the five services as a bottom tab bar; the current one is accent with a top indicator. */ +export function MobileBottomNav({ match, serviceHref, pendingReports, className }: MobileBottomNavProps) { + const services = useVisibleServices().filter((service) => service.sections.length > 0) + const activeId = serviceFor(match).id + + return ( + + ) +} diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 2c770dfa..80f8ae7f 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -3,7 +3,6 @@ import { CircleCheck, CircleQuestionMark, Cloud, - Database, Flag, FolderKanban, FolderTree, @@ -12,6 +11,7 @@ import { LayoutDashboard, type LucideIcon, MessageCircle, + MessagesSquare, Send, ShieldCheck, Tags, @@ -20,9 +20,6 @@ import { UsersRound, } from "lucide-react" -import azureLogo from "@/assets/svg/azure.svg" -import telegramLogo from "@/assets/svg/telegram.svg" -import whatsappLogo from "@/assets/svg/whatsapp.svg" import { urlSegmentsToLabelPath } from "@/features/group-labels/label-tree" import type { FileRoutesByTo } from "@/routeTree.gen" import type { Permission } from "@/server/permissions" @@ -43,15 +40,15 @@ export type Section = { } /** - * `path` is where the service lands (its first section, or its own page for Overview and Account). `logo` is the - * branded mark that replaces `icon` wherever the service is named (rail, panel, sheet, overview, palette); - * `icon` stays as the monochrome fallback. + * `path` is where the service lands (its first section, or its own page for Overview and Account). `icon` is the + * monochrome glyph used wherever the service is named (rail, panel, bottom nav, palette, Overview); `short` is the + * label under it in the mobile bottom nav. */ export type Service = { id: ServiceId title: string + short: string icon: LucideIcon - logo?: string path: DashboardPath sections: Section[] } @@ -80,20 +77,34 @@ export type PageMatch = export type PageKind = PageMatch["kind"] -const overview: Service = { id: "overview", title: "Overview", icon: LayoutDashboard, path: "/dashboard", sections: [] } -const account: Service = { id: "account", title: "Account", icon: UserRound, path: "/dashboard/account", sections: [] } +const overview: Service = { + id: "overview", + title: "Overview", + short: "Overview", + icon: LayoutDashboard, + path: "/dashboard", + sections: [], +} +const account: Service = { + id: "account", + title: "Account", + short: "Account", + icon: UserRound, + path: "/dashboard/account", + sections: [], +} const telegram: Service = { id: "telegram", title: "Telegram", + short: "Telegram", icon: Send, - logo: telegramLogo, path: "/dashboard/telegram/users", sections: [ { id: "users", title: "Users", - icon: UsersRound, + icon: UserRound, path: "/dashboard/telegram/users", permission: "tg:users:read", searchPlaceholder: "Search by name or username…", @@ -101,7 +112,7 @@ const telegram: Service = { { id: "groups", title: "Groups", - icon: Database, + icon: MessagesSquare, path: "/dashboard/telegram/groups", searchPlaceholder: "Search by group name or tag…", }, @@ -119,14 +130,14 @@ const telegram: Service = { const whatsapp: Service = { id: "whatsapp", title: "WhatsApp", + short: "WhatsApp", icon: MessageCircle, - logo: whatsappLogo, path: "/dashboard/whatsapp/groups", sections: [ { id: "groups", title: "Groups", - icon: Database, + icon: MessagesSquare, path: "/dashboard/whatsapp/groups", searchPlaceholder: "Search by group name…", }, @@ -136,14 +147,14 @@ const whatsapp: Service = { const m365: Service = { id: "m365", title: "Microsoft 365", + short: "M365", icon: Cloud, - logo: azureLogo, path: "/dashboard/azure/groups", sections: [ { id: "groups", title: "Groups", - icon: Database, + icon: UsersRound, path: "/dashboard/azure/groups", permission: "azure:groups:read", searchPlaceholder: "Search by group or email…", @@ -151,7 +162,7 @@ const m365: Service = { { id: "members", title: "Members", - icon: UsersRound, + icon: UserRound, path: "/dashboard/azure/members", anyPermission: ["azure:members:read", "azure:members:create"], searchPlaceholder: "Search by name, email or member ID…", @@ -162,6 +173,7 @@ const m365: Service = { const web: Service = { id: "web", title: "Web", + short: "Web", icon: Globe, path: "/dashboard/web/projects", sections: [ @@ -207,6 +219,7 @@ const web: Service = { const reports: Service = { id: "reports", title: "Reports", + short: "Reports", icon: Flag, path: "/dashboard/reports/group-links", sections: [ @@ -229,7 +242,7 @@ const reports: Service = { ], } -/** Rail order: Overview, then the five services with sections, then Account. */ +/** Rail order: Overview (the logo), then the five services with sections, then Account. */ export const services: Service[] = [overview, telegram, whatsapp, m365, web, reports, account] /** The services that own a panel, in rail order. */ diff --git a/src/components/shell/page-bar.tsx b/src/components/shell/page-bar.tsx index 9988bbb2..811c3058 100644 --- a/src/components/shell/page-bar.tsx +++ b/src/components/shell/page-bar.tsx @@ -5,23 +5,14 @@ import { type ValidateLinkOptions, useRouterState, } from "@tanstack/react-router" -import { ArrowLeft, PanelLeft, Search, X } from "lucide-react" -import { - createContext, - type KeyboardEvent, - type ReactNode, - type Ref, - type RefObject, - useContext, - useEffect, - useState, -} from "react" +import { ArrowLeft, Search, X } from "lucide-react" +import { createContext, type KeyboardEvent, type ReactNode, type Ref, useContext } from "react" -import { Button, buttonVariants } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import type { Section, Service } from "./nav" +import type { DashboardPath, Section, Service } from "./nav" /** Content widths per template. The header bar follows the same width. */ export type ContentWidth = "wide" | "tree" | "record" | "settings" | "overview" @@ -29,18 +20,20 @@ export type ContentWidth = "wide" | "tree" | "record" | "settings" | "overview" const widthClass = { wide: "max-w-[1280px]", tree: "max-w-[960px]", - record: "max-w-[1040px]", + record: "max-w-[1200px]", settings: "max-w-[720px]", - overview: "max-w-[1040px]", + overview: "max-w-[1120px]", } satisfies Record export type ShellFrame = { - /** The scroll container, used as the IntersectionObserver root by `ScrollTitle`. */ + /** The page's scroll container. */ main: HTMLElement | null setMain: (main: HTMLElement | null) => void service: Service section: Section | null - openNavigation: () => void + /** Where a service's rail item leads: its last visited section this session, else its first. */ + serviceHref: (service: Service) => DashboardPath + openPalette: () => void } export const ShellFrameContext = createContext(null) @@ -50,7 +43,7 @@ export function useInShell() { return useContext(ShellFrameContext) !== null } -function useShellFrame() { +export function useShellFrame() { const frame = useContext(ShellFrameContext) if (!frame) throw new Error("PageBar and PageContent must be rendered inside DashboardShell.") return frame @@ -65,22 +58,14 @@ export function useRenderedPathname() { return useRouterState({ select: (state) => state.matches.at(-1)?.pathname ?? state.location.pathname }) } -/** Centers the page content at the template width with the shell's side padding. */ -export function PageContent({ width = "wide", children }: { width?: ContentWidth; children: ReactNode }) { - const { setMain } = useShellFrame() - const pathname = useRenderedPathname() +const gutter = "px-4 shell:px-6 min-[1200px]:px-9" +/** Centers the page content at the template width with the shell's side padding, below the page header. */ +export function PageContent({ width = "wide", children }: { width?: ContentWidth; children: ReactNode }) { return ( -
-
-
{children}
-
-
+
+
{children}
+
) } @@ -96,147 +81,104 @@ export type PageBarBack = { /** Match the page's `PageContent` width. */ width?: ContentWidth - /** Section pages: the `Toolbar`. On `< 1024px` it moves to a second row. */ + /** The toolbar row under the header: a `Toolbar` (search, filters, segmented controls). */ left?: ReactNode - /** One primary action, plus at most one `outline` secondary; the primary goes last. */ + /** The page's primary action (last), plus at most one `outline` secondary; record actions on deep pages. */ right?: ReactNode - /** Pages without a panel (Overview, Account): the visible `h1`. */ + /** The `h1`. Section pages default to the section title; Overview and Account pass theirs. */ title?: string - /** Deep pages: the icon-only back button. */ + /** One line of context under the `h1`: a count or the page's purpose. */ + description?: ReactNode + /** Deep pages: the back button. The record's own `h1` is in the content (`RecordHeader`). */ back?: PageBarBack - /** Deep pages: the parent identifier shown after the back button. */ + /** Deep pages: the parent context after the section in the breadcrumb ("Users / 5021007919"). */ context?: ReactNode /** Render `context` in DM Mono, for identifiers like Telegram IDs. */ contextMono?: boolean - /** Deep pages: the content `h1`; its text fades into the bar once it scrolls out of view. */ - scrollTitleRef?: RefObject - /** Text for the scroll title; defaults to the observed element's text. */ - scrollTitle?: string } -const barRow = "h-[51px]" +/** The section panel only appears for services with more than one section; a single one would just repeat the rail. */ +export function servicePanelVisible(service: Service) { + return service.sections.length > 1 +} + +const titleText = "text-[22px]/7 font-semibold tracking-[-0.01em] text-(--pn-fg) shell:text-[24px]/8" /** - * No bottom rule: the bar sits inset like the content below it rather than lining up with the panel header. It - * reserves the same scrollbar gutter as `main` (gutters apply to clipping boxes, hence `overflow-hidden`; menus and - * tooltips render in portals), so toolbar and content share one width and their edges line up. + * The page header, at the top of the scrolling page: the `h1` with one line of context and the actions on the right, + * then the toolbar row. Deep pages show a back button and the breadcrumb instead of the `h1`. Below 760px the actions + * take the full width under the title. */ -export const pageBarFrame = "min-h-13 shrink-0 overflow-hidden bg-(--pn-bg) [scrollbar-gutter:stable] lg:pt-4" -const titleText = "truncate text-[15px]/[22px] font-semibold tracking-[-0.005em] text-(--pn-fg)" - -/** The header bar above `main`, rendered before PageContent on the server and client. */ export function PageBar( props: PageBarProps ): ReactNode -export function PageBar(props: PageBarProps) { - return ( -
- -
- ) -} - -/** The section panel only appears for services with more than one section; a single one would just repeat the rail. */ -export function servicePanelVisible(service: Service) { - return service.sections.length > 1 -} - -/** Also rendered by the shell itself while the page has no `PageBar`, so the navigation toggle stays reachable. */ -export function PageBarContent({ +export function PageBar({ width = "wide", left, right, title, + description, back, context, contextMono = false, - scrollTitleRef, - scrollTitle, }: PageBarProps) { - const { service, section } = useShellFrame() - const hasPanel = servicePanelVisible(service) - const actions = right ?
{right}
: null + const { section } = useShellFrame() + const heading = title ?? section?.title - let bar: ReactNode + let head: ReactNode if (back) { - bar = ( -
-
- + head = ( +
+
- {context ? ( - - {context} - - ) : null} - {scrollTitleRef ? : null} +
- {actions} -
- ) - } else if (title !== undefined) { - bar = ( -
-
- {/* Overview and Account have no panel; the rail is the navigation until it hides below 640px. */} - -

{title}

-
- {actions} + {right ? ( +
{right}
+ ) : null}
) } else { - // Section page: one row at >= 1024 (toolbar | actions); below, "{Service} › {Section}" and actions on - // row 1 (the panel that names both is hidden) and the toolbar alone on row 2. At >= 1024 the rail and panel - // already name the page, so no title is repeated there, even for single-section services without a panel. - bar = ( -
-
- {/* Without a panel the sheet adds nothing to the rail, so the toggle only shows where the rail is hidden. */} - - {section ? ( - - {service.title} › {section.title} - - ) : null} + head = ( +
+
+

{heading}

+ {description ?

{description}

: null}
- {left ? ( -
- {left} + {right ? ( +
+ {right}
) : null} - {actions ?
{actions}
: null}
) } return ( -
-
{bar}
-
- ) -} - -/** Opens the navigation sheet; `className` sets the breakpoint it hides from (`lg:hidden` / `sm:hidden`). */ -function NavigationToggle({ className }: { className: string }) { - const { openNavigation } = useShellFrame() - return ( - - - } - > - - - Open navigation - +
+
+ {head} + {left ?
{left}
: null} +
+
) } @@ -254,8 +196,8 @@ export function BackButton({ label, link }: PageBarBack) { aria-label={text} data-slot="button" className={cn( - buttonVariants({ variant: "ghost", size: "icon-sm" }), - "-ml-2 text-(--pn-fg-muted) hover:text-(--pn-fg) active:translate-y-0 max-lg:ml-0" + buttonVariants({ variant: "outline", size: "icon-sm" }), + "border-(--pn-line-strong) bg-(--pn-surface) text-(--pn-fg) hover:bg-(--pn-muted) active:translate-y-0" )} /> } @@ -267,44 +209,6 @@ export function BackButton({ label, link }: PageBarBack) { ) } -/** - * Fades the record name into the header bar once the content `h1` has scrolled above the top of `main`. The bar - * sits outside the scroller, so the observer needs no root margin. - */ -export function ScrollTitle({ targetRef, title }: { targetRef: RefObject; title?: string }) { - const { main } = useShellFrame() - const [scrolledPast, setScrolledPast] = useState(false) - const [observedText, setObservedText] = useState("") - - useEffect(() => { - const target = targetRef.current - if (!target || !main) return - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry) return - const rootTop = entry.rootBounds?.top ?? 0 - setScrolledPast(!entry.isIntersecting && entry.boundingClientRect.bottom <= rootTop) - setObservedText(target.textContent ?? "") - }, - { root: main, threshold: 0 } - ) - observer.observe(target) - return () => observer.disconnect() - // `title` is a dependency so the observer attaches once a loading page renders its h1. - }, [targetRef, main, title]) - - return ( - - - {title ?? observedText} - - ) -} - export type CountPart = { value: number; total?: number; noun: string; plural?: string } export type CountProps = CountPart & { parts?: CountPart[]; className?: string } @@ -352,8 +256,8 @@ export type SearchFieldProps = { } /** - * 36px search input: `/` focuses it from anywhere, Esc clears it while it has a value, the clear - * button shows only with a value. The page defers the value and resets its pagination. + * 40px search field: `/` focuses it from anywhere (the hint shows on fine pointers), Esc clears it while it has a + * value, the clear button replaces the hint once there is a value. The page defers the value and resets its pagination. */ export function SearchField({ value, onChange, placeholder, label, inputRef, className }: SearchFieldProps) { const { section } = useShellFrame() @@ -368,10 +272,11 @@ export function SearchField({ value, onChange, placeholder, label, inputRef, cla } return ( -
+
{value !== "" ? ( - ) : null} + ) : ( + + / + + )}
) } @@ -410,32 +322,22 @@ export type ToolbarProps = { count?: ReactNode } -/** The section-page left slot: lead → search → filters → count. */ +/** + * The toolbar row under the page header: lead → search → filters → count. Controls are 40px; the row wraps, and below + * 760px the search takes a row of its own. + */ export function Toolbar({ lead, search, filters, count }: ToolbarProps) { return ( -
- {lead || search || filters ? ( -
- {lead} - {search ? ( - - ) : null} - {filters} -
+
+ {lead} + {search ? ( + ) : null} - {count ?
{count}
: null} + {filters} + {count ?
{count}
: null}
) } diff --git a/src/components/shell/panel-sheet.tsx b/src/components/shell/panel-sheet.tsx deleted file mode 100644 index c00416b1..00000000 --- a/src/components/shell/panel-sheet.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { Link } from "@tanstack/react-router" -import { ChevronDown, ChevronRight, Moon, Search, Sun, X } from "lucide-react" -import type { ReactNode } from "react" - -import logoUrl from "@/assets/logo.png" -import { Button } from "@/components/ui/button" -import { Sheet, SheetClose, SheetContent, SheetTitle } from "@/components/ui/sheet" -import { cn } from "@/lib/utils" - -import { AccountAvatar, type ShellUser } from "./account-avatar" -import { type DashboardPath, type PageMatch, type Service, type ServiceId, serviceById, serviceFor } from "./nav" -import { onSectionListKeyDown, SectionItems } from "./panel" -import { ServiceGlyph } from "./service-glyph" -import { themeToggleLabel, useTheme } from "./theme" -import { useVisibleServices } from "./use-can" - -const rowClass = - "flex h-14 w-full items-center gap-3 rounded-(--pn-r-3) px-3 text-left text-[14px] font-medium text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-muted) hover:text-(--pn-fg)" -const rowCurrentClass = "text-(--pn-fg) [&>svg:first-child]:text-(--pn-accent)" -const glyphClass = "size-5 shrink-0" - -export type PanelSheetProps = { - open: boolean - onOpenChange: (open: boolean) => void - /** The service listed with its sections underneath; the rail sets it when it opens the sheet. */ - expanded: ServiceId | null - onExpandedChange: (service: ServiceId | null) => void - match: PageMatch - serviceHref: (service: Service) => DashboardPath - onOpenPalette: () => void - user: ShellUser - pendingReports: Promise -} - -/** - * Navigation below 1024px: the services as 56px rows, the expanded one showing its - * sections. Below 640px the rail is gone too, so Overview, Search, Theme and Account join the list. - */ -export function PanelSheet({ - open, - onOpenChange, - expanded, - onExpandedChange, - match, - serviceHref, - onOpenPalette, - user, - pendingReports, -}: PanelSheetProps) { - const panelServices = useVisibleServices().filter((service) => service.sections.length > 0) - const { theme, toggleTheme } = useTheme() - const currentId = serviceFor(match).id - const close = () => onOpenChange(false) - const overview = serviceById("overview") - const account = serviceById("account") - - return ( - - - Navigation -
- {/* Brand only: the Overview row below is the one link to it. */} -
- - PoliNetwork Admin -
- - } - > - - -
- - - -
- - - {overview.title} - - - - - - {account.title} - -
-
-
- ) -} - -function SheetRow({ - current, - to, - onNavigate, - children, -}: { - current: boolean - to: DashboardPath - onNavigate: () => void - children: ReactNode -}) { - return ( - - {children} - - ) -} diff --git a/src/components/shell/panel.tsx b/src/components/shell/panel.tsx index 76b895b7..45508d43 100644 --- a/src/components/shell/panel.tsx +++ b/src/components/shell/panel.tsx @@ -4,21 +4,22 @@ import { type KeyboardEvent, Suspense, use, useDeferredValue } from "react" import { cn } from "@/lib/utils" import { type PageMatch, type Section, type Service, isDeepPage, sectionFor } from "./nav" -import { ServiceGlyph } from "./service-glyph" const itemClass = - "relative flex h-8 items-center gap-2 rounded-(--pn-r-2) px-2.5 text-[13px] font-medium text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-muted) hover:text-(--pn-fg)" + "relative flex h-9.5 items-center gap-2.5 rounded-(--pn-r-3) px-2.5 text-[14px] font-medium text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-muted) hover:text-(--pn-fg)" -// Active and ancestor share the visual: the indicator sits at the item's left inner edge. -const itemActiveClass = - "bg-(--pn-nav-active) text-(--pn-fg) hover:bg-(--pn-nav-active) before:absolute before:top-1/2 before:left-0 before:h-4 before:w-0.5 before:-translate-y-1/2 before:rounded-[1px] before:bg-(--pn-accent)" +// Active and ancestor share the fill and the accent icon; only the current page gets the indicator on the panel's +// left edge (items are inset 12px, hence -left-3). +const itemActiveClass = "bg-(--pn-nav-active) text-(--pn-fg) hover:bg-(--pn-nav-active)" +const itemCurrentClass = + "before:absolute before:inset-y-2.5 before:-left-3 before:w-[3px] before:rounded-r-[3px] before:bg-(--pn-accent-solid)" -/** Pending reports, shown next to Reports › Open; zero (or a failed load) renders nothing. */ -function PendingReportsCount({ count: countPromise }: { count: Promise }) { - const count = use(countPromise) +/** Pending reports as a small accent pill next to Reports › Open; zero (or a failed load) renders nothing. */ +export function PendingReportsCount({ count: countPromise }: { count: Promise }) { + const count = use(useDeferredValue(countPromise)) if (!count) return null return ( - + , {count} pending @@ -26,14 +27,16 @@ function PendingReportsCount({ count: countPromise }: { count: Promise) { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + const forward = event.key === "ArrowDown" || event.key === "ArrowRight" + const backward = event.key === "ArrowUp" || event.key === "ArrowLeft" + if (!forward && !backward) return const links = Array.from(event.currentTarget.querySelectorAll("a[data-section-item]")) const current = links.findIndex((link) => link === document.activeElement) if (current < 0) return event.preventDefault() - const next = event.key === "ArrowDown" ? (current + 1) % links.length : (current - 1 + links.length) % links.length + const next = forward ? (current + 1) % links.length : (current - 1 + links.length) % links.length links[next]?.focus() } @@ -42,14 +45,20 @@ export type SectionItemsProps = { match: PageMatch /** Streams in from the `/dashboard` loader; resolves to null when the reports couldn't be loaded. */ pendingReports: Promise - onNavigate?: () => void + /** `panel` for the desktop list, `pills` for the horizontally scrolling row under the mobile top bar. */ + variant?: "panel" | "pills" } -/** The section links of one service; shared by the panel and the narrow-screen sheet. */ -export function SectionItems({ service, match, pendingReports, onNavigate }: SectionItemsProps) { +const pillClass = + "inline-flex h-9 shrink-0 items-center gap-1.75 rounded-full border border-(--pn-line) bg-(--pn-surface) px-3 text-[13px] font-medium whitespace-nowrap text-(--pn-fg-muted) transition-[background-color,color,border-color] duration-120 hover:text-(--pn-fg)" +const pillActiveClass = + "border-[color-mix(in_oklch,var(--pn-accent)_45%,transparent)] bg-(--pn-accent-soft) text-(--pn-accent) hover:text-(--pn-accent)" + +/** The section links of one service; shared by the panel and the mobile section pills. */ +export function SectionItems({ service, match, pendingReports, variant = "panel" }: SectionItemsProps) { const current = sectionFor(match) const deep = isDeepPage(match) - const count = useDeferredValue(pendingReports) + const pills = variant === "pills" return service.sections.map((section: Section) => { const active = current?.path === section.path return ( @@ -57,20 +66,28 @@ export function SectionItems({ service, match, pendingReports, onNavigate }: Sec key={section.id} to={section.path} activeOptions={{ exact: true }} - onClick={onNavigate} data-section-item="" aria-current={active && !deep ? "page" : undefined} data-ancestor={active && deep ? "" : undefined} - className={cn(itemClass, active && itemActiveClass)} + className={ + pills + ? cn(pillClass, active && pillActiveClass) + : cn(itemClass, active && itemActiveClass, active && !deep && itemCurrentClass) + } > {section.title} {service.id === "reports" && section.id === "open" ? ( - + ) : null} @@ -85,26 +102,44 @@ export type PanelProps = { className?: string } -/** The 224px section panel. States the service name once, in its header. */ +/** The 240px section panel (200px below 1100px). States the service name once, in its header. */ export function Panel({ service, match, pendingReports, className }: PanelProps) { return ( ) } + +/** Below 760px: the service's sections as a horizontally scrolling row of pills under the top bar. */ +export function SectionPills({ service, match, pendingReports, className }: PanelProps) { + return ( + + ) +} diff --git a/src/components/shell/rail.tsx b/src/components/shell/rail.tsx index 97b8b045..04e5984a 100644 --- a/src/components/shell/rail.tsx +++ b/src/components/shell/rail.tsx @@ -1,6 +1,16 @@ import { Link } from "@tanstack/react-router" import { Moon, Search, Sun } from "lucide-react" -import { type FocusEvent, type KeyboardEvent, type MouseEvent, type ReactNode, useRef, useState } from "react" +import { + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + type ReactNode, + Suspense, + use, + useDeferredValue, + useRef, + useState, +} from "react" import logoUrl from "@/assets/logo.png" import { TOOLTIP_DELAY_SLOW } from "@/components/primitives/hint" @@ -10,19 +20,25 @@ import { cn } from "@/lib/utils" import { AccountAvatar, type ShellUser } from "./account-avatar" import { type DashboardPath, type PageMatch, type Service, serviceById, serviceFor } from "./nav" -import { ServiceGlyph } from "./service-glyph" import { themeToggleLabel, useTheme } from "./theme" import { useVisibleServices } from "./use-can" const railItemClass = "relative grid size-10 shrink-0 place-items-center rounded-(--pn-r-3) text-(--pn-fg-muted) transition-[background-color,color] duration-120 hover:bg-(--pn-muted) hover:text-(--pn-fg)" -// The 2px × 20px indicator sits on the rail's left edge: items are inset 8px, hence -left-2. -const railActiveClass = - "bg-(--pn-accent-soft) text-(--pn-accent) hover:bg-(--pn-accent-soft) hover:text-(--pn-accent) before:absolute before:top-1/2 before:-left-2 before:h-5 before:w-0.5 before:-translate-y-1/2 before:rounded-[1px] before:bg-(--pn-accent)" +// The 3px indicator sits on the rail's left edge: items are inset 12px, hence -left-3. +const railIndicator = + "before:absolute before:inset-y-2 before:-left-3 before:w-[3px] before:rounded-r-[3px] before:bg-(--pn-accent-solid)" +const railActiveClass = cn( + "bg-(--pn-nav-active) text-(--pn-fg) hover:bg-(--pn-nav-active) hover:text-(--pn-fg)", + railIndicator +) +// Logo and avatar are round marks: the active one gets a ring instead of a fill. +const railMarkClass = "rounded-full hover:bg-transparent" +const railMarkActiveClass = cn("[&>*]:shadow-[0_0_0_2px_var(--pn-nav),0_0_0_4px_var(--pn-accent-solid)]", railIndicator) type RailItemTarget = - | { kind: "link"; to: DashboardPath; onClick?: (event: MouseEvent) => void } + | { kind: "link"; to: DashboardPath } | { kind: "button"; onClick: (event: MouseEvent) => void } type RailItemProps = { @@ -36,6 +52,8 @@ type RailItemProps = { target: RailItemTarget /** Marks the theme toggle: the palette's theme switch starts its reveal here, and presses during a reveal find it. */ themeToggle?: boolean + /** Round marks (logo, avatar): a ring shows the active state instead of a fill. */ + mark?: boolean className?: string children: ReactNode } @@ -48,6 +66,7 @@ function RailItem({ focusable, target, themeToggle, + mark = false, className, children, }: RailItemProps) { @@ -56,17 +75,16 @@ function RailItem({ "data-rail-item": "", "data-theme-toggle": themeToggle ? "" : undefined, tabIndex: focusable ? 0 : -1, - className: cn(railItemClass, active && railActiveClass, className), + className: cn( + railItemClass, + mark && railMarkClass, + active && (mark ? railMarkActiveClass : railActiveClass), + className + ), } const trigger = target.kind === "link" ? ( - + ) : (