Compare commits
1 Commits
main
...
feature/en
| Author | SHA1 | Date |
|---|---|---|
|
|
4a164fee21 |
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -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 `<meta name="viewport">` 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
|
||||||
|
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `<Sidebar>` 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 `<html>`
|
||||||
|
- 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 `<main>` 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 `<Link>` using Next.js router, with active state derived from `usePathname()`
|
||||||
|
- Add `pb-16` to `<main>` 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
|
||||||
|
<aside className="hidden lg:flex flex-col ...">
|
||||||
|
{/* existing sidebar content */}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
// Mobile: overlay drawer
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={closeMobile} />
|
||||||
|
<aside className="relative w-72 h-full bg-card animate-slide-in ...">
|
||||||
|
{/* same sidebar content with larger touch targets */}
|
||||||
|
{/* Clusters: showClusters defaults to false */}
|
||||||
|
{/* Libraries: showLibraries defaults to false */}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!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
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<MobileTopBar /> {/* visible only < lg */}
|
||||||
|
<Sidebar /> {/* persistent on lg+, drawer on mobile */}
|
||||||
|
<main className="flex-1 overflow-y-auto pt-14 lg:pt-0 pb-16 lg:pb-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<MobileBottomTabs /> {/* visible only < lg */}
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!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 `<main>` 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. |
|
||||||
|
|
@ -206,3 +206,27 @@
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition-duration: 150ms;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Sidebar from "@/components/sidebar";
|
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({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|
@ -23,12 +26,16 @@ export default function RootLayout({
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} antialiased bg-background text-foreground`}
|
className={`${inter.variable} antialiased bg-background text-foreground`}
|
||||||
>
|
>
|
||||||
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
<SidebarProvider>
|
||||||
<Sidebar />
|
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||||
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto">
|
<MobileTopBar />
|
||||||
{children}
|
<Sidebar />
|
||||||
</main>
|
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto pt-14 lg:pt-0 pb-16 lg:pb-0">
|
||||||
</div>
|
{children}
|
||||||
|
</main>
|
||||||
|
<MobileBottomTabs />
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-40 h-16 bg-card border-t border-border backdrop-blur-lg safe-area-bottom">
|
||||||
|
<div className="flex items-stretch h-full">
|
||||||
|
{tabs.map(({ href, label, icon: Icon }) => {
|
||||||
|
const isActive = pathname === href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col items-center justify-center gap-0.5 min-h-[48px] transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-5 w-5", isActive && "fill-current")} />
|
||||||
|
<span className={cn("text-[10px] font-medium leading-none", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<header className="lg:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-card border-b border-border flex items-center px-4 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={openMobile}
|
||||||
|
aria-label="Open menu"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
||||||
|
<Play className="h-3.5 w-3.5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold text-foreground tracking-tight">NextAV</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
X,
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
Video,
|
Video,
|
||||||
|
|
@ -24,13 +25,14 @@ import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { useSidebar } from "@/contexts/sidebar-context";
|
||||||
|
|
||||||
const SidebarContent = () => {
|
const SidebarContent = () => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const { isCollapsed, isMobileOpen, toggleCollapse, closeMobile, isMobile } = useSidebar();
|
||||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||||
const [showClusters, setShowClusters] = useState(true);
|
const [showClusters, setShowClusters] = useState(!isMobile);
|
||||||
const [showLibraries, setShowLibraries] = useState(true);
|
const [showLibraries, setShowLibraries] = useState(!isMobile);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
|
@ -75,9 +77,13 @@ const SidebarContent = () => {
|
||||||
return icons[iconName] || Folder;
|
return icons[iconName] || Folder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
// When switching to mobile, collapse sections by default
|
||||||
setIsCollapsed(!isCollapsed);
|
useEffect(() => {
|
||||||
};
|
if (isMobile) {
|
||||||
|
setShowClusters(false);
|
||||||
|
setShowLibraries(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Home", icon: Home },
|
{ href: "/", label: "Home", icon: Home },
|
||||||
|
|
@ -88,16 +94,16 @@ const SidebarContent = () => {
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const sidebarInner = (
|
||||||
<div
|
<div className={cn(
|
||||||
className={cn(
|
"flex flex-col bg-card h-full overflow-hidden",
|
||||||
"flex flex-col bg-card border-r border-border transition-all duration-300 ease-in-out shadow-lg h-full overflow-hidden",
|
// Desktop: width transitions; Mobile: full w-72 in drawer
|
||||||
isCollapsed ? "w-16" : "w-64"
|
!isMobile && (isCollapsed ? "w-16" : "w-64"),
|
||||||
)}
|
isMobile && "w-72"
|
||||||
>
|
)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
<div className="w-8 h-8 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
||||||
<Play className="h-4 w-4 text-primary-foreground" />
|
<Play className="h-4 w-4 text-primary-foreground" />
|
||||||
|
|
@ -105,14 +111,25 @@ const SidebarContent = () => {
|
||||||
<h1 className="text-lg font-bold text-foreground tracking-tight">NextAV</h1>
|
<h1 className="text-lg font-bold text-foreground tracking-tight">NextAV</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
{isMobile ? (
|
||||||
onClick={toggleSidebar}
|
<Button
|
||||||
variant="ghost"
|
onClick={closeMobile}
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
size="icon"
|
||||||
>
|
className="h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors ml-auto"
|
||||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
>
|
||||||
</Button>
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -123,15 +140,24 @@ const SidebarContent = () => {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-10 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group",
|
||||||
|
isMobile ? "min-h-[48px] h-auto" : "h-10",
|
||||||
pathname === item.href && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
pathname === item.href && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon className={cn(
|
||||||
"h-4 w-4 transition-colors",
|
"transition-colors flex-shrink-0",
|
||||||
|
isMobile ? "h-5 w-5" : "h-4 w-4",
|
||||||
pathname === item.href ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
pathname === item.href ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||||
)} />
|
)} />
|
||||||
{!isCollapsed && <span className="ml-3 font-medium text-sm">{item.label}</span>}
|
{(!isCollapsed || isMobile) && (
|
||||||
|
<span className={cn(
|
||||||
|
"ml-3 font-medium",
|
||||||
|
isMobile ? "text-base" : "text-sm"
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
@ -144,11 +170,11 @@ const SidebarContent = () => {
|
||||||
onClick={() => setShowClusters(!showClusters)}
|
onClick={() => setShowClusters(!showClusters)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||||
isCollapsed && "justify-center"
|
isCollapsed && !isMobile && "justify-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
||||||
Clusters
|
Clusters
|
||||||
|
|
@ -166,31 +192,23 @@ const SidebarContent = () => {
|
||||||
const IconComponent = getIconComponent(cluster.icon);
|
const IconComponent = getIconComponent(cluster.icon);
|
||||||
const isActive = pathname === `/clusters/${cluster.id}`;
|
const isActive = pathname === `/clusters/${cluster.id}`;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={`/clusters/${cluster.id}`} key={cluster.id} passHref>
|
||||||
href={`/clusters/${cluster.id}`}
|
|
||||||
key={cluster.id}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-9 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
||||||
|
isMobile ? "min-h-[44px] h-auto" : "h-9",
|
||||||
isActive && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
isActive && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-3.5 h-3.5 rounded flex items-center justify-center"
|
className="w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0"
|
||||||
style={{ backgroundColor: `${cluster.color}30` }}
|
style={{ backgroundColor: `${cluster.color}30` }}
|
||||||
>
|
>
|
||||||
<IconComponent
|
<IconComponent className="h-2.5 w-2.5" style={{ color: cluster.color }} />
|
||||||
className="h-2.5 w-2.5"
|
|
||||||
style={{ color: cluster.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<span className="ml-3 truncate text-xs">
|
<span className="ml-3 truncate text-xs">{cluster.name}</span>
|
||||||
{cluster.name}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -208,11 +226,11 @@ const SidebarContent = () => {
|
||||||
onClick={() => setShowLibraries(!showLibraries)}
|
onClick={() => setShowLibraries(!showLibraries)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||||
isCollapsed && "justify-center"
|
isCollapsed && !isMobile && "justify-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
||||||
Libraries
|
Libraries
|
||||||
|
|
@ -235,14 +253,15 @@ const SidebarContent = () => {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-9 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
||||||
|
isMobile ? "min-h-[44px] h-auto" : "h-9",
|
||||||
pathname === "/folder-viewer" &&
|
pathname === "/folder-viewer" &&
|
||||||
searchParams.get("path") === lib.path &&
|
searchParams.get("path") === lib.path &&
|
||||||
"bg-accent text-accent-foreground border border-accent/20"
|
"bg-accent text-accent-foreground border border-accent/20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<span className="ml-3 truncate text-xs">
|
<span className="ml-3 truncate text-xs">
|
||||||
{lib.path.split('/').pop() || lib.path}
|
{lib.path.split('/').pop() || lib.path}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -260,19 +279,45 @@ const SidebarContent = () => {
|
||||||
<div className="p-4 border-t border-border">
|
<div className="p-4 border-t border-border">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-center text-xs text-muted-foreground",
|
"text-center text-xs text-muted-foreground",
|
||||||
isCollapsed && "text-[10px]"
|
isCollapsed && !isMobile && "text-[10px]"
|
||||||
)}
|
)}>
|
||||||
>
|
{(!isCollapsed || isMobile) ? "NextAV v1.0" : "v1.0"}
|
||||||
{!isCollapsed ? "NextAV v1.0" : "v1.0"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: persistent sidebar, hidden on mobile */}
|
||||||
|
<aside className={cn(
|
||||||
|
"hidden lg:flex flex-col bg-card border-r border-border shadow-lg h-full transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isCollapsed ? "w-16" : "w-64"
|
||||||
|
)}>
|
||||||
|
{sidebarInner}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile: overlay drawer */}
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={closeMobile}
|
||||||
|
/>
|
||||||
|
{/* Drawer */}
|
||||||
|
<aside className="relative w-72 h-full animate-slide-in shadow-2xl">
|
||||||
|
{sidebarInner}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="w-16 bg-card border-r border-border" />}>
|
<Suspense fallback={<div className="hidden lg:block w-16 bg-card border-r border-border" />}>
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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<SidebarContextType | undefined>(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 (
|
||||||
|
<SidebarContext.Provider
|
||||||
|
value={{ isCollapsed, isMobileOpen, toggleCollapse, openMobile, closeMobile, isMobile }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
const ctx = useContext(SidebarContext);
|
||||||
|
if (!ctx) throw new Error("useSidebar must be used within SidebarProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue