瀏覽代碼

refactor: update major layouts and styling

satnaing 2 年之前
父節點
當前提交
4fd9ad288e
共有 7 個文件被更改,包括 143 次插入61 次删除
  1. 18 0
      src/components/app-shell.tsx
  2. 52 28
      src/components/nav.tsx
  3. 46 27
      src/components/sidebar.tsx
  4. 17 0
      src/components/theme-switch.tsx
  5. 5 1
      src/hooks/use-check-active-nav.tsx
  6. 1 4
      src/index.css
  7. 4 1
      src/router.tsx

+ 18 - 0
src/components/app-shell.tsx

@@ -0,0 +1,18 @@
+import { Outlet } from 'react-router-dom'
+import Sidebar from './sidebar'
+import useIsCollapsed from '@/hooks/use-is-collapsed'
+
+export default function AppShell() {
+  const [isCollapsed, setIsCollapsed] = useIsCollapsed()
+  return (
+    <div className='relative h-full overflow-hidden bg-background'>
+      <Sidebar isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} />
+      <main
+        id='content'
+        className={`overflow-x-hidden pt-16 transition-[margin] md:overflow-y-hidden md:pt-0 ${isCollapsed ? 'md:ml-14' : 'md:ml-64'} h-full`}
+      >
+        <Outlet />
+      </main>
+    </div>
+  )
+}

+ 52 - 28
src/components/nav.tsx

@@ -1,20 +1,11 @@
 import { Link } from 'react-router-dom'
-import {
-  Tooltip,
-  TooltipContent,
-  TooltipProvider,
-  TooltipTrigger,
-} from './ui/tooltip'
-import { cn } from '@/lib/utils'
+import { IconChevronDown } from '@tabler/icons-react'
 import { Button, buttonVariants } from './ui/button'
 import {
   Collapsible,
   CollapsibleContent,
   CollapsibleTrigger,
-} from '@/components/ui/collapsible'
-import { IconChevronDown } from '@tabler/icons-react'
-import { SideLink } from '@/data/sidelinks'
-
+} from './ui/collapsible'
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -22,40 +13,63 @@ import {
   DropdownMenuLabel,
   DropdownMenuSeparator,
   DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import useCheckActiveNav from '@/hooks/useCheckActiveNav'
+} from './ui/dropdown-menu'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from './ui/tooltip'
+import { cn } from '@/lib/utils'
+import useCheckActiveNav from '@/hooks/use-check-active-nav'
+import { SideLink } from '@/data/sidelinks'
 
 interface NavProps extends React.HTMLAttributes<HTMLDivElement> {
   isCollapsed: boolean
   links: SideLink[]
+  closeNav: () => void
 }
 
-export default function Nav({ links, isCollapsed, className }: NavProps) {
+export default function Nav({
+  links,
+  isCollapsed,
+  className,
+  closeNav,
+}: NavProps) {
   const renderLink = ({ sub, ...rest }: SideLink) => {
     const key = `${rest.title}-${rest.href}`
     if (isCollapsed && sub)
-      return <NavLinkIconDropdown {...rest} sub={sub} key={key} />
+      return (
+        <NavLinkIconDropdown
+          {...rest}
+          sub={sub}
+          key={key}
+          closeNav={closeNav}
+        />
+      )
 
-    if (isCollapsed) return <NavLinkIcon {...rest} key={key} />
+    if (isCollapsed)
+      return <NavLinkIcon {...rest} key={key} closeNav={closeNav} />
 
-    if (sub) return <NavLinkDropdown {...rest} sub={sub} key={key} />
+    if (sub)
+      return (
+        <NavLinkDropdown {...rest} sub={sub} key={key} closeNav={closeNav} />
+      )
 
-    return <NavLink {...rest} key={key} />
+    return <NavLink {...rest} key={key} closeNav={closeNav} />
   }
   return (
     <div
       data-collapsed={isCollapsed}
       className={cn(
-        'group overflow-hidden border-b  py-2 transition-[max-height,padding] duration-500 data-[collapsed=true]:py-2 md:border-none',
+        'group border-b bg-background py-2 transition-[max-height,padding] duration-500 data-[collapsed=true]:py-2 md:border-none',
         className
       )}
     >
       <TooltipProvider delayDuration={0}>
-        <div className='h-auto overflow-auto md:h-svh'>
-          <nav className='grid gap-1 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2'>
-            {links.map(renderLink)}
-          </nav>
-        </div>
+        <nav className='grid gap-1 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2'>
+          {links.map(renderLink)}
+        </nav>
       </TooltipProvider>
     </div>
   )
@@ -63,13 +77,22 @@ export default function Nav({ links, isCollapsed, className }: NavProps) {
 
 interface NavLinkProps extends SideLink {
   subLink?: boolean
+  closeNav: () => void
 }
 
-function NavLink({ title, icon, label, href, subLink = false }: NavLinkProps) {
+function NavLink({
+  title,
+  icon,
+  label,
+  href,
+  closeNav,
+  subLink = false,
+}: NavLinkProps) {
   const { checkActiveNav } = useCheckActiveNav()
   return (
     <Link
       to={href}
+      onClick={closeNav}
       className={cn(
         buttonVariants({
           variant: checkActiveNav(href) ? 'secondary' : 'ghost',
@@ -78,6 +101,7 @@ function NavLink({ title, icon, label, href, subLink = false }: NavLinkProps) {
         'h-12 justify-start text-wrap rounded-none px-6',
         subLink && 'h-10 w-full border-l border-l-slate-500 px-2'
       )}
+      aria-current={checkActiveNav(href) ? 'page' : undefined}
     >
       <div className='mr-2'>{icon}</div>
       {title}
@@ -90,7 +114,7 @@ function NavLink({ title, icon, label, href, subLink = false }: NavLinkProps) {
   )
 }
 
-function NavLinkDropdown({ title, icon, label, sub }: SideLink) {
+function NavLinkDropdown({ title, icon, label, sub, closeNav }: NavLinkProps) {
   const { checkActiveNav } = useCheckActiveNav()
 
   /* Open collapsible by default
@@ -124,7 +148,7 @@ function NavLinkDropdown({ title, icon, label, sub }: SideLink) {
         <ul>
           {sub!.map((sublink) => (
             <li key={sublink.title} className='my-1 ml-8'>
-              <NavLink {...sublink} subLink />
+              <NavLink {...sublink} subLink closeNav={closeNav} />
             </li>
           ))}
         </ul>
@@ -162,7 +186,7 @@ function NavLinkIcon({ title, icon, label, href }: NavLinkProps) {
   )
 }
 
-function NavLinkIconDropdown({ title, icon, label, sub }: SideLink) {
+function NavLinkIconDropdown({ title, icon, label, sub }: NavLinkProps) {
   const { checkActiveNav } = useCheckActiveNav()
 
   /* Open collapsible by default

+ 46 - 27
src/components/sidebar.tsx

@@ -1,33 +1,48 @@
+import { useEffect, useState } from 'react'
+import { IconChevronsLeft, IconMenu2, IconX } from '@tabler/icons-react'
+import { Button } from './ui/button'
 import Nav from './nav'
 import { cn } from '@/lib/utils'
-import { Button } from './ui/button'
 import { sidelinks } from '@/data/sidelinks'
-import { IconChevronsLeft, IconMenu2, IconX } from '@tabler/icons-react'
-import { useState } from 'react'
 
-interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
+interface SidebarProps extends React.HTMLAttributes<HTMLElement> {
   isCollapsed: boolean
   setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>
 }
 
-export default function Sidebar({
+export default function Sidebar2({
   className,
   isCollapsed,
   setIsCollapsed,
 }: SidebarProps) {
   const [navOpened, setNavOpened] = useState(false)
+
+  /* Make body not scrollable when navBar is opened */
+  useEffect(() => {
+    if (navOpened) {
+      document.body.classList.add('overflow-hidden')
+    } else {
+      document.body.classList.remove('overflow-hidden')
+    }
+  }, [navOpened])
+
   return (
-    <div
+    <aside
       className={cn(
-        `relative w-full border-r-2 border-r-slate-100 transition-[width] dark:border-r-slate-900 md:max-w-60 md:pb-12 ${isCollapsed ? 'md:w-14' : 'md:w-64'}`,
+        `fixed left-0 right-0 top-0 z-50 w-full border-r-2 border-r-muted transition-[width] md:bottom-0 md:right-auto md:h-svh ${isCollapsed ? 'md:w-14' : 'md:w-64'}`,
         className
       )}
     >
-      <div className='flex flex-col md:h-svh'>
-        <div
-          className={`flex h-16 w-full items-center justify-between gap-2 border-b px-4 py-2`}
-        >
-          <div className='flex items-center gap-2'>
+      {/* Overlay in mobile */}
+      <div
+        onClick={() => setNavOpened(false)}
+        className={`absolute inset-0 transition-[opacity] delay-100 duration-700 ${navOpened ? 'h-svh opacity-50' : 'h-0 opacity-0'} w-full bg-black md:hidden`}
+      />
+
+      <div className='relative h-full w-full'>
+        {/* Header */}
+        <div className='sidebar-header sticky top-0 flex h-16 items-center justify-between bg-background px-4 py-3 shadow'>
+          <div className={` flex items-center ${!isCollapsed ? 'gap-2' : ''}`}>
             <svg
               xmlns='http://www.w3.org/2000/svg'
               viewBox='0 0 256 256'
@@ -65,6 +80,8 @@ export default function Sidebar({
               <span className='text-xs'>Vite + ShadcnUI</span>
             </div>
           </div>
+
+          {/* Toggle Button in mobile */}
           <Button
             variant='ghost'
             size='icon'
@@ -78,26 +95,28 @@ export default function Sidebar({
           </Button>
         </div>
 
+        {/* Navigation links */}
         <Nav
           id='sidebar-menu'
-          className={
-            navOpened ? 'max-h-screen' : 'max-h-0 py-0 md:max-h-screen md:py-2'
-          }
+          className={`h-full overflow-auto ${navOpened ? 'max-h-screen' : 'max-h-0 py-0 md:max-h-screen md:py-2'}`}
+          closeNav={() => setNavOpened(false)}
           isCollapsed={isCollapsed}
           links={sidelinks}
         />
+
+        {/* Scrollbar width toggle button */}
+        <Button
+          onClick={() => setIsCollapsed((prev) => !prev)}
+          size='icon'
+          variant='outline'
+          className='absolute -right-5 top-1/2 hidden rounded-full md:inline-flex'
+        >
+          <IconChevronsLeft
+            stroke={1.5}
+            className={`h-5 w-5 ${isCollapsed ? 'rotate-180' : ''}`}
+          />
+        </Button>
       </div>
-      <Button
-        onClick={() => setIsCollapsed((prev) => !prev)}
-        size='icon'
-        variant='outline'
-        className='absolute -right-5 top-1/2 hidden rounded-full md:inline-flex'
-      >
-        <IconChevronsLeft
-          stroke={1.5}
-          className={`h-5 w-5 ${isCollapsed ? 'rotate-180' : ''}`}
-        />
-      </Button>
-    </div>
+    </aside>
   )
 }

+ 17 - 0
src/components/theme-switch.tsx

@@ -0,0 +1,17 @@
+import { IconMoon, IconSun } from '@tabler/icons-react'
+import { useTheme } from './theme-provider'
+import { Button } from './ui/button'
+
+export default function ThemeSwitch() {
+  const { theme, setTheme } = useTheme()
+  return (
+    <Button
+      size='icon'
+      variant='ghost'
+      className='rounded-full'
+      onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
+    >
+      {theme === 'light' ? <IconMoon size={20} /> : <IconSun size={20} />}
+    </Button>
+  )
+}

+ 5 - 1
src/hooks/useCheckActiveNav.tsx → src/hooks/use-check-active-nav.tsx

@@ -4,7 +4,11 @@ export default function useCheckActiveNav() {
   const { pathname } = useLocation()
 
   const checkActiveNav = (nav: string) => {
-    return pathname === nav
+    const pathArray = pathname.split('/').filter((item) => item !== '')
+
+    if (nav === '/' && pathArray.length < 1) return true
+
+    return pathArray.includes(nav.replace(/^\//, ''))
   }
 
   return { checkActiveNav }

+ 1 - 4
src/index.css

@@ -99,10 +99,7 @@
   * {
     @apply border-border;
   }
-  html {
-    @apply h-svh md:overflow-hidden;
-  }
   body {
-    @apply bg-background text-foreground;
+    @apply min-h-svh w-full bg-background text-foreground;
   }
 }

+ 4 - 1
src/router.tsx

@@ -1,5 +1,6 @@
 import { Suspense, lazy } from 'react'
 import { Route, Routes } from 'react-router-dom'
+import AppShell from '@/components/app-shell'
 import Loader from '@/components/loader'
 
 const Dashboard = lazy(() => import('@/pages/dashboard'))
@@ -23,7 +24,9 @@ export default function Router() {
         <Route path='/sign-up' element={<SignUp />} />
         <Route path='/forgot-password' element={<ForgotPassword />} />
 
-        <Route path='/' element={<Dashboard />} />
+        <Route path='/' element={<AppShell />} errorElement={<GeneralError />}>
+          <Route path='/' element={<Dashboard />} />
+        </Route>
 
         {/* Error routes */}
         <Route path='/500' element={<GeneralError />} />