diff --git a/data/media.db b/data/media.db index ff6178f..6fe413b 100644 Binary files a/data/media.db and b/data/media.db differ diff --git a/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md b/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md new file mode 100644 index 0000000..838947a --- /dev/null +++ b/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md @@ -0,0 +1,378 @@ +# Mobile-Friendly Sidebar & Responsive Layout Plan + +## Problem Statement + +The current NextAV layout uses a **persistent left sidebar** (`w-64` expanded / `w-16` collapsed) alongside the main content area in a `flex h-screen` container. While this works well on desktop, it is **not mobile-friendly** for several reasons: + +1. **The sidebar always occupies screen width** — on a 375px mobile screen, even the collapsed `w-16` (64px) sidebar consumes ~17% of the viewport, leaving only ~311px for content. +2. **No mobile navigation paradigm** — there is no hamburger menu, bottom navigation bar, or swipe-to-open drawer pattern. The sidebar is always rendered. +3. **The collapse toggle is not a mobile pattern** — the `ChevronLeft`/`ChevronRight` toggle on the sidebar is desktop-oriented; mobile users expect a hamburger icon in a top bar or a bottom tab bar. +4. **Content grids are too dense** — while `getColumnCount()` in the grids already supports 2 columns at `<640px`, the available width is further reduced by the always-visible sidebar. +5. **No `` consideration** — the layout doesn't adapt its core shell for touch devices. + +--- + +## Current Architecture Analysis + +### Root Layout ([layout.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/app/layout.tsx)) + +```tsx +
+ +
+ {children} +
+
+``` + +- The `` is always rendered as a direct flex child. +- No conditional rendering based on screen size. +- No shared state mechanism for mobile sidebar open/close. + +### Sidebar ([sidebar.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/components/sidebar.tsx)) + +- **282 lines**, manages its own `isCollapsed` state locally. +- Contains: nav links (Home, Videos, Photos, Bookmarks, Surprise Me, Settings), collapsible Clusters section, collapsible Libraries section, and a footer. +- Width toggles between `w-64` and `w-16` via CSS transition. +- **No `md:` or `lg:` responsive breakpoint classes** — purely desktop-oriented. + +### Content Pages + +| Page | Component | Grid Behavior | +|------|-----------|---------------| +| `/videos` | `InfiniteVirtualGrid` | 2-7 columns based on `containerWidth` | +| `/photos` | `InfiniteVirtualGrid` | Same as above | +| `/bookmarks` | `InfiniteVirtualGrid` | Same as above | +| `/folder-viewer` | `VirtualizedFolderGrid` | 2-7 columns based on `containerWidth` | +| `/surprise-me` | CSS grid | `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5` | +| `/settings` | Manual layout | `grid-cols-1 lg:grid-cols-3` | +| `/` (Home) | Manual layout | `grid-cols-1 md:grid-cols-2 lg:grid-cols-4` | + +> [!NOTE] +> The virtualized grids (`InfiniteVirtualGrid`, `VirtualizedFolderGrid`) already calculate column count responsively via `containerWidth`. They would benefit automatically from having more width available once the sidebar is hidden on mobile. + +### Tailwind Configuration ([tailwind.config.ts](file:///Users/tigeren/Dev/xorbitlab/nextav/tailwind.config.ts)) + +- TailwindCSS **v3.4.17** (confirmed in `package.json`) +- Uses `darkMode: ["class"]` with `.dark` class on `` +- Default breakpoints (`sm: 640px`, `md: 768px`, `lg: 1024px`, `xl: 1280px`, `2xl: 1536px`) +- No custom mobile breakpoints defined + +--- + +## Design Decisions + +> [!NOTE] +> The following decisions were confirmed by the project owner: +> 1. **YouTube-style bottom tab bar** for primary mobile navigation (not just a hamburger drawer) +> 2. **`lg` breakpoint (1024px)** as the mobile/desktop cutoff — tablets in portrait get the mobile experience +> 3. **Clusters & Libraries sections collapsed by default** in the mobile drawer + +--- + +## Proposed Solution + +### Approach: **Bottom Tab Bar + Hamburger Drawer + Persistent Desktop Sidebar** + +On **desktop** (`lg` and above, ≥1024px): keep the current sidebar as-is (persistent, collapsible). +On **mobile/tablet** (`< lg`, <1024px): hide the sidebar, show a **YouTube-style bottom tab bar** for primary navigation, and provide a **hamburger menu in a slim top bar** that opens the full sidebar as a **slide-in drawer overlay** for secondary navigation (Clusters, Libraries, Settings). + +This mirrors the YouTube mobile app pattern: +- **Bottom tab bar** = quick access to primary sections (Home, Videos, Photos, Bookmarks) +- **Hamburger drawer** = full sidebar with all sections including Clusters, Libraries, Settings +- **Top bar** = branding + hamburger trigger + +--- + +## Detailed Implementation Plan + +### Phase 1: Shared Sidebar State (Context) + +> [!IMPORTANT] +> The sidebar's open/close state currently lives inside `sidebar.tsx` as a local `useState`. To control it from the top bar (hamburger button) and coordinate with the bottom tab bar, we need to lift this state into a React Context. + +#### [NEW] `src/contexts/sidebar-context.tsx` + +Create a `SidebarProvider` context that provides: + +```typescript +interface SidebarContextType { + isCollapsed: boolean; // Desktop collapse state + isMobileOpen: boolean; // Mobile drawer open state + toggleCollapse: () => void; // Desktop toggle + openMobile: () => void; // Open mobile drawer + closeMobile: () => void; // Close mobile drawer + isMobile: boolean; // Current screen is mobile (<1024px) +} +``` + +- Use `window.matchMedia('(min-width: 1024px)')` to detect mobile vs desktop (using `lg` breakpoint). +- Auto-close mobile drawer on route change (listen to `usePathname()`). +- Auto-close mobile drawer when screen resizes above `lg` breakpoint. + +--- + +### Phase 2: Mobile Top Bar Component + +#### [NEW] `src/components/mobile-top-bar.tsx` + +A slim fixed top bar visible only on `< lg` screens: + +``` +┌──────────────────────────────────────────┐ +│ ☰ │ NextAV │ [🔍] │ +└──────────────────────────────────────────┘ +``` + +- **Left**: Hamburger icon (`Menu` from lucide) → triggers `openMobile()` to open the full drawer +- **Center**: "NextAV" branding +- **Right**: Optional search icon or current section indicator +- CSS: `lg:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-card border-b border-border` +- Add `pt-14` to `
` on mobile to account for the fixed top bar height + +> [!NOTE] +> The top bar provides access to the **drawer** (secondary nav). The **bottom tab bar** (Phase 2B) handles primary navigation. + +--- + +### Phase 2B: Bottom Tab Bar Component (YouTube-style) + +#### [NEW] `src/components/mobile-bottom-tabs.tsx` + +A fixed bottom tab bar visible only on `< lg` screens, providing quick access to primary sections: + +``` +┌──────────────────────────────────────────┐ +│ 🏠 │ 🎬 │ 📷 │ 🔖 │ +│ Home │ Videos │ Photos │ Bookmarks │ +└──────────────────────────────────────────┘ +``` + +- **4 primary tabs**: Home, Videos, Photos, Bookmarks +- Active tab highlighted with primary color (filled icon + colored label) +- Inactive tabs use muted color +- CSS: `lg:hidden fixed bottom-0 left-0 right-0 z-40 h-16 bg-card border-t border-border backdrop-blur-lg` +- Uses `safe-area-inset-bottom` padding for devices with home indicators (iPhone notch) +- Each tab is a `` using Next.js router, with active state derived from `usePathname()` +- Add `pb-16` to `
` on mobile to account for the fixed bottom bar height + +**Why 4 tabs?** YouTube uses 4-5 tabs. The remaining sections (Surprise Me, Settings, Clusters, Libraries) live in the hamburger drawer — they're less frequently accessed. + +**Tab touch targets**: Each tab should be at minimum `48px × 48px` (WCAG 2.5.8), with the full tab area being tappable. + +--- + +### Phase 3: Sidebar Refactoring + +#### [MODIFY] `src/components/sidebar.tsx` + +**Desktop behavior** (`lg:` and above): +- Keep current behavior: persistent sidebar, `w-64`/`w-16` collapse toggle. +- Add `hidden lg:flex` to the sidebar container. + +**Mobile behavior** (`< lg`): +- Render as a **fixed overlay drawer** that slides in from the left (opened via hamburger in top bar). +- Include a **backdrop** (`bg-black/50`) that closes the drawer on tap. +- Use `translate-x` animation for the slide-in/out effect. +- The drawer should be `w-72` (slightly wider than desktop's `w-64` for better touch targets). +- Add a close button (`X` icon) in the drawer header. +- Nav items should have larger touch targets (`min-h-[48px]` per Material Design guidelines). +- **Clusters and Libraries sections collapsed by default** — `showClusters` and `showLibraries` should initialize to `false` when in mobile mode. + +**Structural approach** — two render paths in the same component: + +```tsx +// Desktop: persistent sidebar + + +// Mobile: overlay drawer +{isMobileOpen && ( +
+
+ +
+)} +``` + +> [!TIP] +> On mobile, the drawer acts as **secondary navigation** — users who need Clusters, Libraries, Surprise Me, or Settings will open it from the hamburger. The **bottom tab bar** handles the 4 most common destinations without opening the drawer. + +--- + +### Phase 4: Layout Adjustments + +#### [MODIFY] `src/app/layout.tsx` + +```tsx + +
+ {/* visible only < lg */} + {/* persistent on lg+, drawer on mobile */} +
+ {children} +
+ {/* visible only < lg */} +
+
+``` + +> [!WARNING] +> Both `pt-14` (top bar) and `pb-16` (bottom tab bar) must be applied on mobile only (`lg:pt-0 lg:pb-0`) to avoid wasting space on desktop. + +#### [MODIFY] `src/app/globals.css` + +Add animation keyframes for the drawer slide-in: + +```css +@keyframes slideInFromLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes slideOutToLeft { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +.animate-slide-in { + animation: slideInFromLeft 0.3s ease-out forwards; +} + +.animate-slide-out { + animation: slideOutToLeft 0.3s ease-in forwards; +} +``` + +--- + +### Phase 5: Touch & Mobile UX Enhancements + +#### Touch Targets +- Increase nav button heights from `h-10` to `min-h-[48px]` on mobile (WCAG 2.5.8). +- Increase icon sizes from `h-4 w-4` to `h-5 w-5` on mobile. +- Increase font sizes from `text-sm` to `text-base` on mobile for nav labels. + +#### Swipe Gesture (Optional, Phase 2) +- Add a swipe-right gesture on the left edge of the screen to open the drawer. +- Add a swipe-left gesture on the open drawer to close it. +- Can be implemented with pointer events (`onTouchStart`, `onTouchMove`, `onTouchEnd`). + +#### Content Adjustments +- The `InfiniteVirtualGrid` and `VirtualizedFolderGrid` will automatically gain more width since the sidebar no longer takes space on mobile. +- Consider reducing padding from `p-6` to `p-3` on mobile for content pages. + +--- + +## Files to Change + +| File | Action | Summary | +|------|--------|---------| +| `src/contexts/sidebar-context.tsx` | **NEW** | Shared sidebar state context with mobile detection at `lg` breakpoint | +| `src/components/mobile-top-bar.tsx` | **NEW** | Mobile-only slim top bar with hamburger + branding | +| `src/components/mobile-bottom-tabs.tsx` | **NEW** | YouTube-style bottom tab bar (Home, Videos, Photos, Bookmarks) | +| `src/components/sidebar.tsx` | **MODIFY** | Dual-mode: persistent desktop (`lg:flex`) + overlay drawer mobile; Clusters/Libraries collapsed by default on mobile | +| `src/app/layout.tsx` | **MODIFY** | Wrap with `SidebarProvider`, add `MobileTopBar` + `MobileBottomTabs`, adjust `
` padding (`pt-14 pb-16 lg:pt-0 lg:pb-0`) | +| `src/app/globals.css` | **MODIFY** | Add slide-in/out animations, safe-area-inset-bottom support | +| `tailwind.config.ts` | **MODIFY** (optional) | Add custom animation keyframes if preferred over raw CSS | + +--- + +## Visual Mockup + +### Desktop (≥1024px) — No Change + +``` +┌─────────────┬────────────────────────────────────────┐ +│ ▶ NextAV │ │ +│─────────────│ Main Content Area │ +│ 🏠 Home │ │ +│ 🎬 Videos │ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ 📷 Photos │ │ Card │ │ Card │ │ Card │ │ +│ 🔖 Bookmarks│ └──────┘ └──────┘ └──────┘ │ +│ ✨ Surprise │ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ ⚙ Settings │ │ Card │ │ Card │ │ Card │ │ +│─────────────│ └──────┘ └──────┘ └──────┘ │ +│ Clusters │ │ +│ Libraries │ │ +│─────────────│ │ +│ NextAV v1.0│ │ +└─────────────┴────────────────────────────────────────┘ +``` + +### Mobile/Tablet (<1024px) — Bottom Tab Bar + Top Bar + +``` +┌──────────────────────────┐ +│ ☰ │ NextAV │ 🔍 │ ← slim top bar (h-14) +├──────────────────────────┤ +│ │ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ │ +├──────────────────────────┤ +│ 🏠 Home │🎬 Videos│📷 Photos│🔖 Marks│ ← bottom tab bar (h-16) +└──────────────────────────┘ +``` + +### Mobile/Tablet — Hamburger Drawer Open + +``` +┌──────────────────────────┐ +│ ▶ NextAV ✕ │▒▒▒▒▒│ +│────────────────────│▒▒▒▒▒│ +│ 🏠 Home │▒▒▒▒▒│ +│ 🎬 Videos │▒▒▒▒▒│ +│ 📷 Photos │▒▒▒▒▒│ +│ 🔖 Bookmarks │▒▒▒▒▒│ +│ ✨ Surprise Me │▒▒▒▒▒│ +│ ⚙ Settings │▒▒▒▒▒│ +│────────────────────│▒▒▒▒▒│ +│ ▸ Clusters (collapsed) │ +│ ▸ Libraries (collapsed) │ +│────────────────────│▒▒▒▒▒│ +│ NextAV v1.0 │▒▒▒▒▒│ +└──────────────────────────┘ + ▲ drawer (w-72) ▲ backdrop + + Note: Clusters & Libraries are collapsed + by default on mobile. User can expand them. +``` + +--- + +## Implementation Priority + +| Priority | Task | Effort | +|----------|------|--------| +| P0 | Create `SidebarProvider` context | Small | +| P0 | Create `MobileTopBar` component | Small | +| P0 | Create `MobileBottomTabs` component (YouTube-style) | Medium | +| P0 | Refactor `sidebar.tsx` for desktop/mobile dual mode | Medium | +| P0 | Update `layout.tsx` to integrate all pieces | Small | +| P0 | Add CSS animations for drawer + safe-area support | Small | +| P1 | Increase touch targets on mobile nav items & drawer | Small | +| P1 | Reduce content padding on mobile (`p-6` → `p-3`) | Small | +| P2 | Swipe gesture to open/close drawer | Medium | + +--- + +## Resolved Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Bottom Tab Bar vs Hamburger Drawer? | **YouTube-style bottom tab bar** for primary nav (Home, Videos, Photos, Bookmarks). Hamburger drawer retained for secondary nav (Surprise Me, Settings, Clusters, Libraries). | +| 2 | Breakpoint Selection | **`lg` (1024px)** — tablets in portrait mode get the mobile experience with bottom tab bar + drawer. | +| 3 | Sidebar Sections on Mobile | **Collapsed by default** — Clusters and Libraries sections start collapsed in the mobile drawer to reduce scroll length. Users can expand them manually. | diff --git a/src/app/globals.css b/src/app/globals.css index 8cff6d4..ccb1407 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -206,3 +206,27 @@ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } + +/* Mobile sidebar drawer animations */ +@keyframes slideInFromLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes slideOutToLeft { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +.animate-slide-in { + animation: slideInFromLeft 0.3s ease-out forwards; +} + +.animate-slide-out { + animation: slideOutToLeft 0.3s ease-in forwards; +} + +/* Safe area bottom padding for devices with home indicator */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7464b60..75883d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import Sidebar from "@/components/sidebar"; +import MobileTopBar from "@/components/mobile-top-bar"; +import MobileBottomTabs from "@/components/mobile-bottom-tabs"; +import { SidebarProvider } from "@/contexts/sidebar-context"; const inter = Inter({ variable: "--font-inter", @@ -23,12 +26,16 @@ export default function RootLayout({ -
- -
- {children} -
-
+ +
+ + +
+ {children} +
+ +
+
); diff --git a/src/components/mobile-bottom-tabs.tsx b/src/components/mobile-bottom-tabs.tsx new file mode 100644 index 0000000..69979e9 --- /dev/null +++ b/src/components/mobile-bottom-tabs.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Home, Film, Image as ImageIcon, Bookmark } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const tabs = [ + { href: "/", label: "Home", icon: Home }, + { href: "/videos", label: "Videos", icon: Film }, + { href: "/photos", label: "Photos", icon: ImageIcon }, + { href: "/bookmarks", label: "Bookmarks", icon: Bookmark }, +]; + +export default function MobileBottomTabs() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/mobile-top-bar.tsx b/src/components/mobile-top-bar.tsx new file mode 100644 index 0000000..33adcc6 --- /dev/null +++ b/src/components/mobile-top-bar.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Menu, Play } from "lucide-react"; +import { useSidebar } from "@/contexts/sidebar-context"; + +export default function MobileTopBar() { + const { openMobile } = useSidebar(); + + return ( +
+ + +
+
+ +
+ NextAV +
+
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 788020a..3a92b8c 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, + X, Home, Settings, Video, @@ -24,13 +25,14 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; import { Suspense } from "react"; +import { useSidebar } from "@/contexts/sidebar-context"; const SidebarContent = () => { - const [isCollapsed, setIsCollapsed] = useState(false); + const { isCollapsed, isMobileOpen, toggleCollapse, closeMobile, isMobile } = useSidebar(); const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]); const [clusters, setClusters] = useState([]); - const [showClusters, setShowClusters] = useState(true); - const [showLibraries, setShowLibraries] = useState(true); + const [showClusters, setShowClusters] = useState(!isMobile); + const [showLibraries, setShowLibraries] = useState(!isMobile); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -75,9 +77,13 @@ const SidebarContent = () => { return icons[iconName] || Folder; }; - const toggleSidebar = () => { - setIsCollapsed(!isCollapsed); - }; + // When switching to mobile, collapse sections by default + useEffect(() => { + if (isMobile) { + setShowClusters(false); + setShowLibraries(false); + } + }, [isMobile]); const navItems = [ { href: "/", label: "Home", icon: Home }, @@ -88,16 +94,16 @@ const SidebarContent = () => { { href: "/settings", label: "Settings", icon: Settings }, ]; - return ( -
+ const sidebarInner = ( +
{/* Header */}
- {!isCollapsed && ( + {(!isCollapsed || isMobile) && (
@@ -105,14 +111,25 @@ const SidebarContent = () => {

NextAV

)} - + {isMobile ? ( + + ) : ( + + )}
{/* Navigation */} @@ -123,15 +140,24 @@ const SidebarContent = () => { ))} @@ -144,11 +170,11 @@ const SidebarContent = () => { onClick={() => setShowClusters(!showClusters)} className={cn( "flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors", - isCollapsed && "justify-center" + isCollapsed && !isMobile && "justify-center" )} > - {!isCollapsed && ( + {(!isCollapsed || isMobile) && ( <>

Clusters @@ -166,31 +192,23 @@ const SidebarContent = () => { const IconComponent = getIconComponent(cluster.icon); const isActive = pathname === `/clusters/${cluster.id}`; return ( - + @@ -208,11 +226,11 @@ const SidebarContent = () => { onClick={() => setShowLibraries(!showLibraries)} className={cn( "flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors", - isCollapsed && "justify-center" + isCollapsed && !isMobile && "justify-center" )} > - {!isCollapsed && ( + {(!isCollapsed || isMobile) && ( <>

Libraries @@ -235,14 +253,15 @@ const SidebarContent = () => {

); + + return ( + <> + {/* Desktop: persistent sidebar, hidden on mobile */} + + + {/* Mobile: overlay drawer */} + {isMobileOpen && ( +
+ {/* Backdrop */} +
+ {/* Drawer */} + +
+ )} + + ); }; const Sidebar = () => { return ( - }> + }> ); diff --git a/src/contexts/sidebar-context.tsx b/src/contexts/sidebar-context.tsx new file mode 100644 index 0000000..e454e2e --- /dev/null +++ b/src/contexts/sidebar-context.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +interface SidebarContextType { + isCollapsed: boolean; + isMobileOpen: boolean; + toggleCollapse: () => void; + openMobile: () => void; + closeMobile: () => void; + isMobile: boolean; +} + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isMobileOpen, setIsMobileOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const pathname = usePathname(); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 1023px)"); + const handler = (e: MediaQueryListEvent | MediaQueryList) => { + setIsMobile(e.matches); + if (!e.matches) setIsMobileOpen(false); + }; + handler(mq); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // Auto-close mobile drawer on route change + useEffect(() => { + setIsMobileOpen(false); + }, [pathname]); + + const toggleCollapse = useCallback(() => setIsCollapsed((v) => !v), []); + const openMobile = useCallback(() => setIsMobileOpen(true), []); + const closeMobile = useCallback(() => setIsMobileOpen(false), []); + + return ( + + {children} + + ); +} + +export function useSidebar() { + const ctx = useContext(SidebarContext); + if (!ctx) throw new Error("useSidebar must be used within SidebarProvider"); + return ctx; +}