diff --git a/eslint.config.js b/eslint.config.js index 28c6cf8..bea40b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,4 +22,10 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['src/shared/ui/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]) diff --git a/package.json b/package.json index 531bb6d..df59d3a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ ] }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "@tanstack/react-router": "^1.144.0", @@ -38,6 +42,7 @@ "react-dom": "^19.2.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", + "vaul": "^1.1.2", "zod": "^4.3.4", "zustand": "^5.0.9" }, diff --git a/src/app/providers/Providers.tsx b/src/app/providers/Providers.tsx new file mode 100644 index 0000000..d14da2f --- /dev/null +++ b/src/app/providers/Providers.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from 'react' + +import { SidebarProvider } from '@/shared/ui/sidebar' + +export function Providers({ children }: PropsWithChildren) { + return {children} +} diff --git a/src/assets/instagram-logo.svg b/src/assets/instagram-logo.svg new file mode 100644 index 0000000..f69ce6b --- /dev/null +++ b/src/assets/instagram-logo.svg @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/src/features/create-post/ui/CreateModal.tsx b/src/features/create-post/ui/CreateModal.tsx new file mode 100644 index 0000000..50e9c15 --- /dev/null +++ b/src/features/create-post/ui/CreateModal.tsx @@ -0,0 +1,16 @@ +import { Dialog, DialogContent } from '@/shared/ui/dialog' + +type CreateModalProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CreateModal({ open, onOpenChange }: CreateModalProps) { + return ( + + +
+ +
+ ) +} diff --git a/src/features/search/ui/SearchDrawer.tsx b/src/features/search/ui/SearchDrawer.tsx new file mode 100644 index 0000000..fc4f03c --- /dev/null +++ b/src/features/search/ui/SearchDrawer.tsx @@ -0,0 +1,80 @@ +import { cn } from '@/shared/lib/utils' +import type { CSSProperties, RefObject } from 'react' +import { useLayoutEffect, useMemo, useState } from 'react' + +type SearchDrawerProps = { + open: boolean + onOpenChange: (open: boolean) => void + anchorRef?: RefObject +} + +const DEFAULT_ANCHOR_RIGHT_PX = 0 + +export function SearchDrawer({ + open, + onOpenChange, + anchorRef, +}: SearchDrawerProps) { + const close = () => onOpenChange(false) + const [anchorRightPx, setAnchorRightPx] = useState(DEFAULT_ANCHOR_RIGHT_PX) + + useLayoutEffect(() => { + const el = anchorRef?.current + if (!el) return + + const update = () => { + setAnchorRightPx(el.getBoundingClientRect().right) + } + + update() + + const ro = new ResizeObserver(update) + ro.observe(el) + + window.addEventListener('resize', update) + window.addEventListener('scroll', update, { passive: true }) + + return () => { + ro.disconnect() + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update) + } + }, [anchorRef, open]) + + const anchoredStyle = useMemo( + () => ({ left: anchorRightPx }), + [anchorRightPx] + ) + + return ( + <> +
+
+ +
+ + ) +} diff --git a/src/index.css b/src/index.css index 82bee87..6d67bdb 100644 --- a/src/index.css +++ b/src/index.css @@ -1,189 +1,9 @@ @import 'tailwindcss'; -@import 'tw-animate-css'; - -@custom-variant dark (&:is(.dark *)); - -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} - -@theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} @layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; + html, + body, + #root { + @apply h-full w-full; } } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..5e336fc --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,3 @@ +export const HomePage = () => { + return
HomePage
+} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index faa5300..f5c47ae 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as MeRouteImport } from './routes/me' import { Route as LoginRouteImport } from './routes/login' import { Route as ExploreRouteImport } from './routes/explore' import { Route as Profile_nameRouteImport } from './routes/$profile_name' @@ -17,6 +18,11 @@ import { Route as PProfile_nameRouteImport } from './routes/p/$profile_name' import { Route as Profile_nameSavedRouteImport } from './routes/$profile_name/saved' import { Route as StoriesProfile_nameStory_idRouteImport } from './routes/stories/$profile_name/$story_id' +const MeRoute = MeRouteImport.update({ + id: '/me', + path: '/me', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -59,6 +65,7 @@ export interface FileRoutesByFullPath { '/$profile_name': typeof Profile_nameRouteWithChildren '/explore': typeof ExploreRoute '/login': typeof LoginRoute + '/me': typeof MeRoute '/$profile_name/saved': typeof Profile_nameSavedRoute '/p/$profile_name': typeof PProfile_nameRoute '/stories/$profile_name/$story_id': typeof StoriesProfile_nameStory_idRoute @@ -68,6 +75,7 @@ export interface FileRoutesByTo { '/$profile_name': typeof Profile_nameRouteWithChildren '/explore': typeof ExploreRoute '/login': typeof LoginRoute + '/me': typeof MeRoute '/$profile_name/saved': typeof Profile_nameSavedRoute '/p/$profile_name': typeof PProfile_nameRoute '/stories/$profile_name/$story_id': typeof StoriesProfile_nameStory_idRoute @@ -78,6 +86,7 @@ export interface FileRoutesById { '/$profile_name': typeof Profile_nameRouteWithChildren '/explore': typeof ExploreRoute '/login': typeof LoginRoute + '/me': typeof MeRoute '/$profile_name/saved': typeof Profile_nameSavedRoute '/p/$profile_name': typeof PProfile_nameRoute '/stories/$profile_name/$story_id': typeof StoriesProfile_nameStory_idRoute @@ -89,6 +98,7 @@ export interface FileRouteTypes { | '/$profile_name' | '/explore' | '/login' + | '/me' | '/$profile_name/saved' | '/p/$profile_name' | '/stories/$profile_name/$story_id' @@ -98,6 +108,7 @@ export interface FileRouteTypes { | '/$profile_name' | '/explore' | '/login' + | '/me' | '/$profile_name/saved' | '/p/$profile_name' | '/stories/$profile_name/$story_id' @@ -107,6 +118,7 @@ export interface FileRouteTypes { | '/$profile_name' | '/explore' | '/login' + | '/me' | '/$profile_name/saved' | '/p/$profile_name' | '/stories/$profile_name/$story_id' @@ -117,12 +129,20 @@ export interface RootRouteChildren { Profile_nameRoute: typeof Profile_nameRouteWithChildren ExploreRoute: typeof ExploreRoute LoginRoute: typeof LoginRoute + MeRoute: typeof MeRoute PProfile_nameRoute: typeof PProfile_nameRoute StoriesProfile_nameStory_idRoute: typeof StoriesProfile_nameStory_idRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/me': { + id: '/me' + path: '/me' + fullPath: '/me' + preLoaderRoute: typeof MeRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -184,7 +204,7 @@ const Profile_nameRouteChildren: Profile_nameRouteChildren = { } const Profile_nameRouteWithChildren = Profile_nameRoute._addFileChildren( - Profile_nameRouteChildren + Profile_nameRouteChildren, ) const rootRouteChildren: RootRouteChildren = { @@ -192,6 +212,7 @@ const rootRouteChildren: RootRouteChildren = { Profile_nameRoute: Profile_nameRouteWithChildren, ExploreRoute: ExploreRoute, LoginRoute: LoginRoute, + MeRoute: MeRoute, PProfile_nameRoute: PProfile_nameRoute, StoriesProfile_nameStory_idRoute: StoriesProfile_nameStory_idRoute, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e4bbb8f..1011379 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,11 +1,14 @@ import { createRootRoute, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' +import { Providers } from '@/app/providers/Providers' export const Route = createRootRoute({ component: () => ( - <> - +
+ + + {import.meta.env.DEV ? : null} - +
), }) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3d58f2c..0d54087 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,36 +1,6 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { HomePage } from '@/pages/HomePage' export const Route = createFileRoute('/')({ component: HomePage, }) - -function HomePage() { - return ( -
-

Route Examples

-
- /login - /explore - - /user1 - - - /user1/saved - - - /stories/user1/123 - - - /p/user1?img_index=2 - -
-
- ) -} diff --git a/src/routes/me.tsx b/src/routes/me.tsx new file mode 100644 index 0000000..7783da8 --- /dev/null +++ b/src/routes/me.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/me')({ + component: RouteComponent, +}) + +// TODO: add redirect after getting user data +function RouteComponent() { + return
Hello "/me"!
+} diff --git a/src/shared/lib/hooks/use-mobile.ts b/src/shared/lib/hooks/use-mobile.ts new file mode 100644 index 0000000..4331d5c --- /dev/null +++ b/src/shared/lib/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from 'react' + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} diff --git a/src/shared/ui/button.tsx b/src/shared/ui/button.tsx new file mode 100644 index 0000000..0b3cd7d --- /dev/null +++ b/src/shared/ui/button.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/shared/lib/utils' + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..4c2be56 --- /dev/null +++ b/src/shared/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' + +import { cn } from '@/shared/lib/utils' + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/shared/ui/drawer.tsx b/src/shared/ui/drawer.tsx new file mode 100644 index 0000000..0963804 --- /dev/null +++ b/src/shared/ui/drawer.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' +import { Drawer as DrawerPrimitive } from 'vaul' + +import { cn } from '@/shared/lib/utils' + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/shared/ui/input.tsx b/src/shared/ui/input.tsx new file mode 100644 index 0000000..f745121 --- /dev/null +++ b/src/shared/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { cn } from '@/shared/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/src/shared/ui/separator.tsx b/src/shared/ui/separator.tsx new file mode 100644 index 0000000..7d508fb --- /dev/null +++ b/src/shared/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import * as SeparatorPrimitive from '@radix-ui/react-separator' + +import { cn } from '@/shared/lib/utils' + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/shared/ui/sheet.tsx b/src/shared/ui/sheet.tsx new file mode 100644 index 0000000..cdbc61c --- /dev/null +++ b/src/shared/ui/sheet.tsx @@ -0,0 +1,139 @@ +'use client' + +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' + +import { cn } from '@/shared/lib/utils' + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left' +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/shared/ui/sidebar.tsx b/src/shared/ui/sidebar.tsx new file mode 100644 index 0000000..204316e --- /dev/null +++ b/src/shared/ui/sidebar.tsx @@ -0,0 +1,738 @@ +'use client' + +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { PanelLeftIcon } from 'lucide-react' + +import { useIsMobile } from '@/shared/lib/hooks/use-mobile' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Separator } from '@/shared/ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/shared/ui/sheet' +import { Skeleton } from '@/shared/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/shared/ui/tooltip' + +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContextProps = { + 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 +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + 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} +
+
+
+ ) +} + +type SidebarProps = React.ComponentPropsWithoutRef<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' +} + +const Sidebar = React.forwardRef(function Sidebar( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref +) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +}) + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar() + + return ( +