diff --git a/package.json b/package.json index a7d05b4..5067a3f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", @@ -36,7 +37,8 @@ "react-hook-form": "^7.53.0", "react-redux": "^9.1.2", "tailwind-merge": "^2.5.3", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "useismobile": "^1.0.4" }, "devDependencies": { "@codedependant/semantic-release-docker": "^5.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 184e030..04f6b2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.11)(react@18.3.1) @@ -89,6 +92,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.13) + useismobile: + specifier: ^1.0.4 + version: 1.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@codedependant/semantic-release-docker': specifier: ^5.0.3 @@ -571,6 +577,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.0': + resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -2431,6 +2450,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + useismobile@1.0.4: + resolution: {integrity: sha512-EUjKD4IfJb+CHCP7Xaw++vUTl65aIE5LCQOAzyPonOMlRrsfX1Ur5k0GBaEyxTKpSuj3SvvcqK/CdfA+K1gN0Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.2.0 + react-dom: ^16.8.0 || ^17.0.1 || ^18.2.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2937,6 +2962,15 @@ snapshots: '@types/react': 18.3.11 '@types/react-dom': 18.3.0 + '@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.1.0(@types/react@18.3.11)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.11)(react@18.3.1) @@ -5120,6 +5154,11 @@ snapshots: dependencies: react: 18.3.1 + useismobile@1.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + util-deprecate@1.0.2: {} vfile-location@4.1.0: diff --git a/src/app/(gistLayout)/layout-ui.tsx b/src/app/(gistLayout)/layout-ui.tsx index 931e1a7..7af4fea 100644 --- a/src/app/(gistLayout)/layout-ui.tsx +++ b/src/app/(gistLayout)/layout-ui.tsx @@ -1,174 +1,231 @@ -import { OrgListFeature } from "@/components/logic/org-list-logic"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/shadcn/avatar"; -import { Button } from "@/components/shadcn/button"; -import { Codearea } from "@/components/shadcn/codearea"; -import { Input } from "@/components/shadcn/input"; -import MenuButton from "@/components/ui/menu-button"; -import { Modal } from "@/components/ui/modal"; -import { ProfileDropdown } from "@/components/ui/profile-dropdown"; -import Shortcut from "@/components/ui/shortcut"; -import TooltipShortcut, { - TooltipShortcutTrigger, -} from "@/components/ui/tooltip-shortcut"; -import { getLanguage } from "@/lib/language"; -import { FileCodeIcon, LucidePencil, Menu, PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { OrgListFeature } from '@/components/logic/org-list-logic' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' +import { Button } from '@/components/shadcn/button' +import { Codearea } from '@/components/shadcn/codearea' +import { Input } from '@/components/shadcn/input' +import { Sidebar, SidebarContent, SidebarHeader, SidebarProvider } from '@/components/shadcn/sidebar' +import MenuButton from '@/components/ui/menu-button' +import { Modal } from '@/components/ui/modal' +import { ProfileDropdown } from '@/components/ui/profile-dropdown' +import TooltipShortcut, { TooltipShortcutTrigger } from '@/components/ui/tooltip-shortcut' +import { getLanguage } from '@/lib/language' +import { FileCodeIcon, LucidePencil, PlusIcon } from 'lucide-react' +import { useState } from 'react' interface GistLayoutProps { - username: string; - avatar: string; - children: React.ReactNode; - onMyGists: () => void; - onCreateOrg: (name: string) => void; - onCreateGist: (name: string, content: string) => void; - onLogout: () => void; + username: string + avatar: string + children: React.ReactNode + onMyGists: () => void + onCreateOrg: (name: string) => void + onCreateGist: (name: string, content: string) => void + onLogout: () => void } -export default function GistLayout({ +export default function GistLayout({ avatar, children, username, onMyGists, onCreateOrg, onCreateGist, onLogout }: GistLayoutProps) { + const [gistName, setGistName] = useState('') + const [gistContent, setGistContent] = useState('') + const [isGistModalOpen, setIsGistModalOpen] = useState(false) + const [orgName, setOrgName] = useState('') + const [isOrgModalOpen, setIsOrgModalOpen] = useState(false) + + const language = getLanguage(gistName) + + return ( + +
+ + {children} +
+
+ ) +} + +interface AppSidebarProps { + avatar: string + username: string + gistName: string + setGistName: (name: string) => void + isGistModalOpen: boolean + setIsGistModalOpen: (open: boolean) => void + onCreateGist: (name: string, content: string) => void + onLogout: () => void + orgName: string + setOrgName: (name: string) => void + isOrgModalOpen: boolean + setIsOrgModalOpen: (open: boolean) => void + onCreateOrg: (name: string) => void + onMyGists: () => void + gistContent: string + setGistContent: (content: string) => void + language: string +} + +function AppSidebar({ avatar, - children, username, - onMyGists, - onCreateOrg, + gistName, + setGistName, + isGistModalOpen, + setIsGistModalOpen, onCreateGist, onLogout, -}: GistLayoutProps) { - const [gistName, setGistName] = useState(""); - const [gistContent, setGistContent] = useState(""); - const [orgName, setOrgName] = useState(""); - const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); - - const language = getLanguage(gistName); - - const handleCreateGistClick = () => { - onCreateGist(gistName, gistContent); - setGistName(""); - setGistContent(""); - }; - + orgName, + setOrgName, + isOrgModalOpen, + setIsOrgModalOpen, + onCreateOrg, + onMyGists, + gistContent, + setGistContent, + language, +}: AppSidebarProps) { return ( -
-
-
-
+ +
+ +
- - {username.charAt(0).toUpperCase()} - + {username.charAt(0).toUpperCase()}
- - - - - - - -
- } - content={ -
- setGistName(e.target.value)} - /> - setGistContent(e.target.value)} - /> -
- } - footer={ - - Create - - } - > +
+ +
- } - variant="menu" - size="menu" - letter="M" - onClick={onMyGists} - href="/mygist" - className="w-full" - > + } variant="menu" size="menu" letter="M" onClick={onMyGists} href="/mygist" className="w-full"> My Gists - } - variant="menu" - size="menu" - letter="T" - className="w-full" - > - Create org - - } - title="Create Org" - content={ -
- setOrgName(e.target.value)} - /> -
- } - footer={ - { - onCreateOrg(orgName); - setOrgName(""); - setIsOrgModalOpen(false); - }} - > - Create - - } - /> +
-
+
- {children} -
- ); + + ) +} + +interface CreateGistModalProps { + gistName: string + setGistName: (name: string) => void + isGistModalOpen: boolean + setIsGistModalOpen: (open: boolean) => void + onCreateGist: (name: string, content: string) => void + gistContent: string + setGistContent: (content: string) => void + language: string +} + +function CreateGistModal({ gistName, setGistName, isGistModalOpen, setIsGistModalOpen, onCreateGist, gistContent, setGistContent, language }: CreateGistModalProps) { + return ( + + + + + + +
+ } + content={ +
+ setGistName(e.target.value)} /> +
+ setGistContent(e.target.value)} /> +
+
+ } + footer={ + { + onCreateGist(gistName, gistContent) + setGistName('') + setIsGistModalOpen(false) + }} + > + Create + + } + > + ) +} + +interface CreateOrgModalProps { + orgName: string + setOrgName: (name: string) => void + isOrgModalOpen: boolean + setIsOrgModalOpen: (open: boolean) => void + onCreateOrg: (name: string) => void +} + +function CreateOrgModal({ orgName, setOrgName, setIsOrgModalOpen, onCreateOrg, isOrgModalOpen }: CreateOrgModalProps) { + return ( + } variant="menu" size="menu" letter="T" className="w-full"> + Create org + + } + title="Create Org" + content={ +
+ setOrgName(e.target.value)} /> +
+ } + footer={ + { + onCreateOrg(orgName) + setOrgName('') + setIsOrgModalOpen(false) + }} + > + Create + + } + /> + ) } diff --git a/src/app/(gistLayout)/layout.tsx b/src/app/(gistLayout)/layout.tsx index f8ad22b..043bc6f 100644 --- a/src/app/(gistLayout)/layout.tsx +++ b/src/app/(gistLayout)/layout.tsx @@ -1,80 +1,66 @@ -"use client"; +'use client' -import { ReactNode, useCallback } from "react"; -import GistLayout from "./layout-ui"; -import { useMe } from "@/lib/queries/user.queries"; -import { useToast } from "@/components/shadcn/use-toast"; -import { useCreateGist } from "@/lib/queries/gists.queries"; -import { useCreateOrg } from "@/lib/queries/orgs.queries"; -import { useLogout } from "@/lib/queries/auth.queries"; -import { redirect } from "next/navigation"; -import { useRouter } from "next/router"; +import { ReactNode, useCallback } from 'react' +import GistLayout from './layout-ui' +import { useMe } from '@/lib/queries/user.queries' +import { useToast } from '@/components/shadcn/use-toast' +import { useCreateGist } from '@/lib/queries/gists.queries' +import { useCreateOrg } from '@/lib/queries/orgs.queries' +import { useLogout } from '@/lib/queries/auth.queries' -export default function GistLayoutFeature({ - children, -}: { - children: ReactNode; -}) { - const { data, error } = useMe(); - const { toast } = useToast(); +export default function GistLayoutFeature({ children }: { children: ReactNode }) { + const { data, error } = useMe() + const { toast } = useToast() const { mutate: createGist } = useCreateGist({ onSuccess: () => { toast({ - title: "Gist Created", - description: "Your gist has been created successfully", - }); + title: 'Gist Created', + description: 'Your gist has been created successfully', + }) }, - }); - + }) const { mutate: createOrg } = useCreateOrg({ onSuccess: () => { toast({ - title: "Organization Created", - description: "Your org has been created successfully", - }); + title: 'Organization Created', + description: 'Your org has been created successfully', + }) }, - }); + }) const { mutate: logout } = useLogout({ onSuccess: () => { toast({ - title: "Logged Out", - description: "You have been logged out successfully", - }); - window.location.href = "/"; //sorry but couldn't find a way to redirect to the login page + title: 'Logged Out', + description: 'You have been logged out successfully', + }) + window.location.href = '/' //sorry but couldn't find a way to redirect to the login page }, - }); + }) - const onMyGists = () => {}; + const onMyGists = () => {} const onCreateOrg = useCallback( (name: string) => { - createOrg(name); + createOrg(name) }, - [createOrg], - ); + [createOrg] + ) const onLogout = () => { - logout(); - }; + logout() + } const onCreateGist = (name: string, content: string) => { createGist({ content, name, - }); - }; + }) + } return ( - + {children} - ); + ) } diff --git a/src/app/(gistLayout)/mygist/page-ui.tsx b/src/app/(gistLayout)/mygist/page-ui.tsx index aa8387d..7dbf497 100644 --- a/src/app/(gistLayout)/mygist/page-ui.tsx +++ b/src/app/(gistLayout)/mygist/page-ui.tsx @@ -1,4 +1,5 @@ import { MyGistListFeature } from '@/components/logic/mygist-list-logic' +import { SidebarTrigger } from '@/components/shadcn/sidebar' import MenuButton from '@/components/ui/menu-button' import { PaginationComponent } from '@/components/ui/pagination' import TooltipShortcut, { TooltipShortcutTrigger } from '@/components/ui/tooltip-shortcut' @@ -8,9 +9,13 @@ interface MyGistPageProps {} export default function MyGistsPage({}: MyGistPageProps) { return ( -
-
- My Gists +
+
+
+ +
+ My Gists +
} variant={'menu'}> @@ -22,7 +27,7 @@ export default function MyGistsPage({}: MyGistPageProps) {
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index 1f66bd0..604be02 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -30,6 +30,14 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } */ .dark { @@ -60,6 +68,14 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --sidebar-background: 210 7% 5%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 217 33% 17%; + --sidebar-ring: 217.2 91.2% 59.8%; } .light { @@ -90,6 +106,14 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } h1 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 82cc5be..3392d4c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,9 +7,52 @@ import { Toaster } from '@/components/shadcn/toaster' import { Providers } from '@/components/theme/theme-provider' import QueryProvider from '@/components/api/api-provider' import Script from 'next/script' +import { Metadata } from 'next' const fontSans = FontSans({ subsets: ['latin'] }) +export const metadata: Metadata = { + title: 'Create and share secure code snippets - Gists', + description: 'Gists lets developers create, share, and collaborate on secure code snippets.', + metadataBase: new URL('https://gists.app'), + icons: { + icon: '/favicon.png', + }, + keywords: [ + 'gists', + 'app', + 'code snippets', + 'code sharing', + 'developer tools', + 'programming', + 'collaboration', + 'open source', + 'project management', + 'code editor', + 'gist platform', + 'coding platform', + 'software development', + 'team collaboration', + 'version control', + 'code storage', + ], + openGraph: { + title: 'Create and share secure code snippets - Gists', + description: 'Gists lets developers create, share, and collaborate on secure code snippets.', + type: 'website', + url: 'https://gists.app', + siteName: 'Gists', + images: [ + { + url: 'https://gists.app/og-card.png', + width: 1200, + height: 630, + alt: 'Preview image for Gists.app', + }, + ], + }, +} + export default function RootLayout({ children, }: Readonly<{ diff --git a/src/app/page.tsx b/src/app/page.tsx index eed525e..c81b07a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,51 +1,8 @@ -import { Metadata } from 'next' import GistsLandingLogic from '@/components/logic/gists-landing-logic' -export const metadata: Metadata = { - title: 'Create and share secure code snippets - Gists', - description: 'Gists lets developers create, share, and collaborate on secure code snippets.', - metadataBase: new URL('https://gists.app'), - icons: { - icon: '/favicon.png', - }, - keywords: [ - 'gists', - 'app', - 'code snippets', - 'code sharing', - 'developer tools', - 'programming', - 'collaboration', - 'open source', - 'project management', - 'code editor', - 'gist platform', - 'coding platform', - 'software development', - 'team collaboration', - 'version control', - 'code storage', - ], - openGraph: { - title: 'Create and share secure code snippets - Gists', - description: 'Gists lets developers create, share, and collaborate on secure code snippets.', - type: 'website', - url: 'https://gists.app', - siteName: 'Gists', - images: [ - { - url: 'https://gists.app/og-card.png', - width: 1200, - height: 630, - alt: 'Preview image for Gists.app', - }, - ], - }, -} - export default function HomePage() { return ( -
+
diff --git a/src/components/shadcn/separator.tsx b/src/components/shadcn/separator.tsx new file mode 100644 index 0000000..b6fe39b --- /dev/null +++ b/src/components/shadcn/separator.tsx @@ -0,0 +1,21 @@ +'use client' + +import * as React from 'react' +import * as SeparatorPrimitive from '@radix-ui/react-separator' + +import { cn } from '@/lib/utils' + +const Separator = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/shadcn/sheet.tsx b/src/components/shadcn/sheet.tsx new file mode 100644 index 0000000..4eb2719 --- /dev/null +++ b/src/components/shadcn/sheet.tsx @@ -0,0 +1,76 @@ +'use client' + +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { cva, type VariantProps } from 'class-variance-authority' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + } +) + +interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) =>
+SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) =>
+SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } diff --git a/src/components/shadcn/sidebar.tsx b/src/components/shadcn/sidebar.tsx new file mode 100644 index 0000000..e841fba --- /dev/null +++ b/src/components/shadcn/sidebar.tsx @@ -0,0 +1,557 @@ +'use client' + +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { VariantProps, cva } from 'class-variance-authority' +import { PanelLeft } from 'lucide-react' + +import { useIsMobile } from '@/lib/hook/use-is-mobile' +import { cn } from '@/lib/utils' +import { Button } from './button' +import { Input } from './input' +import { Separator } from './separator' +import { Sheet, SheetContent } from './sheet' +import { Skeleton } from './skeleton' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip' + +const SIDEBAR_COOKIE_NAME = 'sidebar:state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '18rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContext = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +}) +SidebarProvider.displayName = 'SidebarProvider' + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' + } +>(({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +}) +Sidebar.displayName = 'Sidebar' + +const SidebarTrigger = React.forwardRef, React.ComponentProps>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = 'SidebarTrigger' + +const SidebarRail = React.forwardRef>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +