Files
vfxreview/components/layout/Sidebar.tsx
T
twotalesanimation 40b99f3add
Deploy / deploy (push) Successful in 3m19s
feat(ui): usable on phones without changing the desktop layout
At phone width the sidebar stayed 240px wide, leaving pages a ~135px strip.
Below md it is now hidden behind a menu button in the header and opens as a
drawer (closes on navigation, backdrop, X or Escape); from md up the sidebar
is unchanged. Page wrappers use p-4 on phones and keep p-6/p-8 from md.

Shot detail: on phones the thumbnail takes the full width with the details
below it instead of pushing them off screen, the action buttons wrap, and
the tab bar scrolls sideways. Desktop sizing is kept as it was.

Every change is behind a md: breakpoint, so the desktop layout is the same.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:56:38 +02:00

186 lines
6.4 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Josefin_Sans } from 'next/font/google';
import { Montserrat } from 'next/font/google';
import {
LayoutDashboard,
FolderOpen,
Users,
UserCog,
Settings,
ChevronLeft,
ChevronRight,
ListTodo,
CalendarRange,
BarChart2,
ListVideo,
CloudUpload,
Clapperboard,
LayoutGrid,
Server,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { useMobileNav } from '@/components/layout/MobileNav';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/projects', label: 'Projects', icon: FolderOpen },
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/pipeline', label: 'Pipeline', icon: Server, hideForClient: true },
{ href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true },
{ href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true },
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
{ href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true },
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
{ href: '/settings', label: 'Settings', icon: Settings },
];
const josefin = Josefin_Sans({
subsets: ['latin'],
weight: ['300', '400'],
});
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const { open: mobileOpen, setOpen: setMobileOpen } = useMobileNav();
// Picking a page closes the phone drawer; so does Escape.
useEffect(() => {
setMobileOpen(false);
}, [pathname, setMobileOpen]);
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setMobileOpen(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mobileOpen, setMobileOpen]);
return (
<>
{/* md and up: the sidebar exactly as before. */}
<aside
className={cn(
'hidden md:flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
collapsed ? 'w-16' : 'w-60',
)}
>
<SidebarContent collapsed={collapsed} />
{/* Collapse toggle */}
<div className="border-t border-zinc-800 p-2">
<Button
variant="ghost"
size="icon-sm"
className="w-full"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
</aside>
{/* Phones: the same navigation as a drawer, opened from the header. */}
{mobileOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileOpen(false)} />
<aside className="absolute inset-y-0 left-0 flex w-64 max-w-[80vw] flex-col overflow-y-auto border-r border-zinc-800 bg-zinc-900">
<button
type="button"
onClick={() => setMobileOpen(false)}
className="absolute right-2 top-2 rounded-md p-2 text-zinc-400 hover:bg-zinc-800 hover:text-white"
aria-label="Close menu"
>
<X className="h-4 w-4" />
</button>
<SidebarContent collapsed={false} />
</aside>
</div>
)}
</>
);
}
function SidebarContent({ collapsed }: { collapsed: boolean }) {
const pathname = usePathname();
const { data: session } = useSession();
const isAdmin = ['ADMIN', 'PRODUCER'].includes(session?.user?.role ?? '');
return (
<>
{/* Logo */}
<div
className={cn(
'flex items-center gap-3 px-4 py-5 border-b border-zinc-800',
collapsed && 'justify-center px-0',
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
{!collapsed && (
<div className={montserrat.className}>
<span className="block text-2xl font-light text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
vfx review
</span>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-4 space-y-1">
{navItems.map((item) => {
if (item.adminOnly && !isAdmin) return null;
if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null;
if ((item as any).hideForClient && session?.user?.role === 'CLIENT') return null;
if ((item as any).supervisorOnly && !['ADMIN', 'PRODUCER', 'SUPERVISOR'].includes(session?.user?.role ?? '')) return null;
const Icon = item.icon;
const isActive =
pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
collapsed && 'justify-center px-0 w-full',
isActive
? 'bg-amber-500/10 text-amber-400'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800',
)}
title={collapsed ? item.label : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
</>
);
}