fix(ui): persist sidebar state (#3446)

This commit is contained in:
aliang 2024-11-21 13:44:43 +07:00 committed by GitHub
parent 405204236c
commit cf610f9e90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 371 additions and 9 deletions

View File

@ -0,0 +1,33 @@
'use client'
import { useHydrated } from '@/lib/hooks/use-hydration'
import {
toggleSidebar,
useUserPreferencesStore
} from '@/lib/stores/user-preferences-store'
import { SidebarProvider } from '@/components/ui/sidebar'
import MainContent from './dashboard-main'
import AppSidebar from './dashboard-sidebar'
export default function Layout({ children }: { children: React.ReactNode }) {
const hydrated = useHydrated()
const isSidebarExpanded = useUserPreferencesStore(
state => state.isSidebarExpanded
)
if (!hydrated) return null
return (
<>
<SidebarProvider
className="relative"
open={isSidebarExpanded}
onOpenChange={open => toggleSidebar(open)}
>
<AppSidebar />
<MainContent>{children}</MainContent>
</SidebarProvider>
</>
)
}

View File

@ -0,0 +1,297 @@
'use client'
import React, { FunctionComponent } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import logoDarkUrl from '@/assets/logo-dark.png'
import logoUrl from '@/assets/logo.png'
import tabbyLogo from '@/assets/tabby.png'
import { HoverCardPortal } from '@radix-ui/react-hover-card'
import { useMe } from '@/lib/hooks/use-me'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from '@/components/ui/hover-card'
import {
IconBookOpenText,
IconChevronRight,
IconGear,
IconLightingBolt,
IconUser
} from '@/components/ui/icons'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar
} from '@/components/ui/sidebar'
import LoadingWrapper from '@/components/loading-wrapper'
export interface SidebarProps {
children?: React.ReactNode
className?: string
}
type SubMenu = {
title: string
href: string
allowUser?: boolean
}
type Menu =
| {
title: string
icon: FunctionComponent
allowUser?: boolean
items: SubMenu[]
}
| {
title: string
href: string
icon: FunctionComponent
allowUser?: boolean
items?: never
}
const menus: Menu[] = [
{
title: 'Profile',
icon: IconUser,
href: '/profile',
allowUser: true
},
{
title: 'Information',
icon: IconBookOpenText,
items: [
{
title: 'System',
href: '/system'
},
{
title: 'Jobs',
href: '/jobs'
},
{
title: 'Reports',
href: '/reports'
},
{
title: 'Activities',
href: '/activities'
}
]
},
{
title: 'Settings',
icon: IconGear,
allowUser: true,
items: [
{
title: 'General',
href: '/settings/general'
},
{
title: 'Users & Groups',
href: '/settings/team',
allowUser: true
},
{
title: 'Subscription',
href: '/settings/subscription'
}
]
},
{
title: 'Integrations',
icon: IconLightingBolt,
items: [
{
title: 'Context Providers',
href: '/settings/providers/git'
},
{
title: 'SSO',
href: '/settings/sso'
},
{
title: 'Mail Delivery',
href: '/settings/mail'
}
]
}
]
export default function AppSidebar() {
const pathname = usePathname()
const [{ data, fetching: fetchingMe }] = useMe()
const isAdmin = data?.me.isAdmin
const { isMobile, state } = useSidebar()
return (
<Sidebar
style={{
position: 'absolute',
top: 0,
bottom: 0
}}
collapsible="icon"
>
<SidebarHeader>
<Link
href="/"
className="flex h-[3.375rem] items-center justify-center py-2"
>
<>
<Image
src={tabbyLogo}
width={32}
alt="logo"
className="hidden group-data-[collapsible=icon]:block"
/>
<div className="w-[128px] group-data-[collapsible=icon]:hidden">
<Image
src={logoUrl}
alt="logo"
className="dark:hidden"
width={128}
/>
<Image
src={logoDarkUrl}
alt="logo"
width={96}
className="hidden dark:block"
/>
</div>
</>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="list-none space-y-2 text-sm font-medium leading-normal">
<LoadingWrapper loading={fetchingMe}>
{menus.map(menu => {
if (isAdmin || menu.allowUser) {
if (menu.items) {
return (
<Collapsible
defaultOpen
asChild
className="group/collapsible"
key={`collapsible_${menu.title}`}
>
<SidebarMenuItem>
<HoverCard openDelay={200} closeDelay={200}>
<HoverCardTrigger asChild>
<CollapsibleTrigger asChild>
<SidebarMenuButton key={menu.title}>
{!!menu.icon && <menu.icon />}
<span>{menu.title}</span>
<IconChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
align="start"
side="right"
sideOffset={4}
hidden={state !== 'collapsed' || isMobile}
className="w-[theme(space.48)] py-2"
>
<div key={menu.title}>
<div className="mb-2 ml-2 mt-1 text-sm font-medium text-muted-foreground">
{menu.title}
</div>
<div className="space-y-1">
{menu.items.map(item => {
if (isAdmin || item.allowUser) {
return (
<SidebarMenuButton
key={item.title}
asChild
isActive={pathname.startsWith(
item.href
)}
>
<Link href={item.href}>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
)
} else {
return null
}
})}
</div>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
<CollapsibleContent>
<SidebarMenuSub>
{menu.items.map(item => {
if (isAdmin || item.allowUser) {
return (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton
asChild
isActive={pathname.startsWith(item.href)}
>
<Link href={item.href}>
<span>{item.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
}
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
} else {
return (
<SidebarMenuItem key={menu.title}>
<SidebarMenuButton
asChild
isActive={pathname.startsWith(menu.href)}
tooltip={{
children: (
<span className="text-sm font-medium text-muted-foreground">
{menu.title}
</span>
)
}}
>
<Link href={menu.href}>
{!!menu.icon && <menu.icon />}
<span>{menu.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
}
return null
})}
</LoadingWrapper>
</SidebarGroup>
</SidebarContent>
</Sidebar>
)
}

View File

@ -1,19 +1,17 @@
import { Metadata } from 'next' import { Metadata } from 'next'
import { SidebarProvider } from '@/components/ui/sidebar'
import { LicenseBanner } from '@/components/license-banner' import { LicenseBanner } from '@/components/license-banner'
import MainContent from './components/main-content' import Layout from './components/dashboard-layout'
import AppSidebar from './components/sidebar'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: 'Home', default: 'Dashboard',
template: `Tabby - %s` template: `Tabby - %s`
} }
} }
export default function RootLayout({ export default function DashboardLayout({
children children
}: { }: {
children: React.ReactNode children: React.ReactNode
@ -21,10 +19,7 @@ export default function RootLayout({
return ( return (
<> <>
<LicenseBanner /> <LicenseBanner />
<SidebarProvider className="relative"> <Layout>{children}</Layout>
<AppSidebar />
<MainContent>{children}</MainContent>
</SidebarProvider>
</> </>
) )
} }

View File

@ -0,0 +1,37 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface UserPreferences {
isSidebarExpanded: boolean
_hasHydrated: boolean
setHasHydrated: (v: boolean) => void
}
const initialState: Omit<UserPreferences, 'setHasHydrated'> = {
isSidebarExpanded: true,
_hasHydrated: false
}
export const useUserPreferencesStore = create<UserPreferences>()(
persist(
set => ({
...initialState,
setHasHydrated: (v: boolean) => set(() => ({ _hasHydrated: v }))
}),
{
name: 'user-preferences-storage',
version: 0,
onRehydrateStorage: state => {
return () => {
state.setHasHydrated(true)
}
}
}
)
)
const set = useUserPreferencesStore.setState
export const toggleSidebar = (expanded: boolean) => {
return set(() => ({ isSidebarExpanded: expanded }))
}