mirror of
https://github.com/TabbyML/tabby
synced 2024-11-22 00:08:06 +00:00
fix(ui): persist sidebar state (#3446)
This commit is contained in:
parent
405204236c
commit
cf610f9e90
33
ee/tabby-ui/app/(dashboard)/components/dashboard-layout.tsx
vendored
Normal file
33
ee/tabby-ui/app/(dashboard)/components/dashboard-layout.tsx
vendored
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
297
ee/tabby-ui/app/(dashboard)/components/dashboard-sidebar.tsx
vendored
Normal file
297
ee/tabby-ui/app/(dashboard)/components/dashboard-sidebar.tsx
vendored
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
13
ee/tabby-ui/app/(dashboard)/layout.tsx
vendored
13
ee/tabby-ui/app/(dashboard)/layout.tsx
vendored
@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
37
ee/tabby-ui/lib/stores/user-preferences-store.ts
vendored
Normal file
37
ee/tabby-ui/lib/stores/user-preferences-store.ts
vendored
Normal 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 }))
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user