34af26dfbf
A /board page that replaces the shared doc used as the day-to-day tracker. One card per active/on-hold project with a status line, dated milestones and to-do lines typed or pasted in the doc's own format: DH2_VFX_027_090 finalise (CHRIS) DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020 Shot codes link to the shot (live status + latest version), (NAME)/@name assigns, wait: marks a blocker, headings group lines. Codes that match no shot are flagged, with a relink when there is an obvious near miss. Board lines are their own model, not Tasks: tasks drive shot status and notifications, and a note like "finalise" must not touch either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.5 KiB
TypeScript
188 lines
6.5 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,
|
|
NotebookPen,
|
|
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: '/board', label: 'Board', icon: NotebookPen, hideForClient: true },
|
|
{ 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>
|
|
</>
|
|
);
|
|
}
|