| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- 'use client';
- import { useEffect, useState } from 'react';
- import { useRouter, usePathname } from 'next/navigation';
- import Link from 'next/link';
- import { useAuth } from '@/lib/auth-context';
- import { Avatar } from '@/components/ui/avatar';
- function formatBytes(bytes: number): string {
- if (bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
- }
- export default function DashboardLayout({ children }: { children: React.ReactNode }) {
- const { user, loading, logout } = useAuth();
- const router = useRouter();
- const pathname = usePathname();
- const [sidebarOpen, setSidebarOpen] = useState(false);
- useEffect(() => {
- if (!loading && !user) {
- router.push('/login');
- }
- }, [user, loading, router]);
- // Close drawer on route change
- useEffect(() => {
- setSidebarOpen(false);
- }, [pathname]);
- if (loading) {
- return (
- <div className="min-h-screen flex items-center justify-center"
- style={{ background: 'var(--bg)' }}>
- <div className="flex flex-col items-center gap-4 animate-fade-in">
- <div className="w-11 h-11 rounded-xl flex items-center justify-center"
- style={{ background: '#6366F1', boxShadow: '0 0 28px rgba(99,102,241,0.5)' }}>
- <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
- <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- </div>
- <div className="flex items-center gap-2.5">
- <div className="w-4 h-4 rounded-full animate-spin"
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
- <span className="text-sm" style={{ color: 'var(--text-muted)' }}>Loading…</span>
- </div>
- </div>
- </div>
- );
- }
- if (!user) return null;
- const isActive = (href: string) =>
- href === '/' ? pathname === '/' : pathname.startsWith(href);
- const SidebarContent = () => (
- <>
- {/* Logo */}
- <div className="py-4 flex justify-center md:justify-start md:px-4"
- style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
- <Link href="/projects" className="flex items-center gap-2.5 group">
- <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
- style={{ background: '#6366F1', boxShadow: '0 0 16px rgba(99,102,241,0.4)' }}>
- <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
- <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- </div>
- <span className="font-semibold tracking-tight text-sm hidden md:block" style={{ color: 'var(--text)' }}>
- VidReview
- </span>
- </Link>
- </div>
- {/* Nav */}
- <nav className="flex-1 px-3 py-4 overflow-y-auto">
- <NavSection label="Workspace">
- <NavLink
- href="/projects"
- active={isActive('/projects')}
- onClick={() => setSidebarOpen(false)}
- icon={
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
- </svg>
- }
- >
- Projects
- </NavLink>
- </NavSection>
- {/* Secondary links */}
- <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
- {user.globalRole === 'ADMIN' && (
- <NavLink
- href="/users"
- active={isActive('/users')}
- onClick={() => setSidebarOpen(false)}
- icon={
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
- </svg>
- }
- >
- Users
- </NavLink>
- )}
- <NavLink
- href="/settings"
- active={isActive('/settings')}
- onClick={() => setSidebarOpen(false)}
- icon={
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
- </svg>
- }
- >
- Settings
- </NavLink>
- </div>
- </nav>
- {/* User / logout */}
- <div className="py-3 px-3 shrink-0"
- style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
- {/* Storage quota bar */}
- {(user.storageQuota ?? 0) > 0 && (
- <div className="mb-2 px-1">
- <div className="flex items-center justify-between mb-1">
- <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>Storage</span>
- <span className="text-[10px] font-mono" style={{ color: 'var(--text-subtle)' }}>
- {formatBytes(user.storageUsed ?? 0)} / {formatBytes(user.storageQuota ?? 0)}
- </span>
- </div>
- <div className="h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
- <div
- className="h-full rounded-full transition-all"
- style={{
- width: `${Math.min(100, ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) * 100)}%`,
- background: ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) > 0.85 ? '#EF4444' : '#6366F1',
- }}
- />
- </div>
- </div>
- )}
- <div className="flex items-center gap-2.5 p-2 rounded-lg"
- style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
- <Avatar name={user.name} src={user.avatarUrl} size="md" />
- <div className="flex-1 min-w-0 hidden md:block">
- <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
- <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
- {user.globalRole.toLowerCase()}
- </p>
- </div>
- <button
- onClick={async () => {
- await logout();
- router.push('/login');
- }}
- className="p-1.5 rounded-md transition-colors hover:bg-white/5"
- title="Sign out"
- >
- <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
- </svg>
- </button>
- </div>
- </div>
- </>
- );
- return (
- <div className="min-h-screen flex" style={{ background: 'var(--bg)' }}>
- {/* ── Mobile hamburger button ─────────────────────────── */}
- <button
- onClick={() => setSidebarOpen(true)}
- className="fixed top-3 left-3 z-50 p-2 rounded-lg"
- style={{
- background: 'rgba(10,11,20,0.95)',
- border: '1px solid rgba(255,255,255,0.10)',
- backdropFilter: 'blur(8px)',
- color: 'var(--text-muted)',
- }}
- aria-label="Open menu"
- >
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
- </svg>
- </button>
- {/* ── Mobile overlay backdrop ─────────────────────────── */}
- {sidebarOpen && (
- <div
- className="fixed inset-0 z-40 md:hidden"
- style={{ background: 'rgba(0,0,0,0.6)' }}
- onClick={() => setSidebarOpen(false)}
- />
- )}
- {/* ── Sidebar (drawer on mobile, fixed sidebar on desktop) ─ */}
- <aside
- className={`
- fixed inset-y-0 left-0 z-50 md:z-auto md:h-screen
- flex flex-col shrink-0 overflow-hidden
- transition-transform duration-200 ease-out md:transition-none
- w-56 md:w-56
- ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
- `}
- style={{
- background: 'rgba(10,11,20,0.97)',
- borderRight: '1px solid rgba(255,255,255,0.06)',
- backdropFilter: 'blur(16px)',
- }}
- >
- {/* Close button on mobile */}
- <button
- onClick={() => setSidebarOpen(false)}
- className="absolute top-3 right-3 p-1.5 rounded-lg md:hidden hover:bg-white/5"
- style={{ color: 'var(--text-muted)' }}
- aria-label="Close menu"
- >
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- <SidebarContent />
- </aside>
- {/* ── Main content (padding-left on desktop for fixed sidebar, padding-top on mobile for hamburger) ─── */}
- <main className="flex-1 overflow-auto min-w-0 md:ml-56 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
- {children}
- </main>
- </div>
- );
- }
- function NavSection({ label, children }: { label?: string; children: React.ReactNode }) {
- return (
- <div>
- {label && (
- <p className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-widest mb-1"
- style={{ color: 'var(--text-subtle)' }}>{label}</p>
- )}
- {children}
- </div>
- );
- }
- function NavLink({
- href,
- active,
- icon,
- children,
- onClick,
- }: {
- href: string;
- active: boolean;
- icon: React.ReactNode;
- children: React.ReactNode;
- onClick?: () => void;
- }) {
- return (
- <Link
- href={href}
- onClick={onClick}
- className={`nav-item mb-0.5${active ? ' nav-item-active' : ''}`}
- >
- <span className="shrink-0">{icon}</span>
- <span>{children}</span>
- </Link>
- );
- }
|