layout.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { useRouter, usePathname } from 'next/navigation';
  4. import Link from 'next/link';
  5. import { useAuth } from '@/lib/auth-context';
  6. import { Avatar } from '@/components/ui/avatar';
  7. function formatBytes(bytes: number): string {
  8. if (bytes === 0) return '0 B';
  9. const k = 1024;
  10. const sizes = ['B', 'KB', 'MB', 'GB'];
  11. const i = Math.floor(Math.log(bytes) / Math.log(k));
  12. return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
  13. }
  14. export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  15. const { user, loading, logout } = useAuth();
  16. const router = useRouter();
  17. const pathname = usePathname();
  18. const [sidebarOpen, setSidebarOpen] = useState(false);
  19. useEffect(() => {
  20. if (!loading && !user) {
  21. router.push('/login');
  22. }
  23. }, [user, loading, router]);
  24. // Close drawer on route change
  25. useEffect(() => {
  26. setSidebarOpen(false);
  27. }, [pathname]);
  28. if (loading) {
  29. return (
  30. <div className="min-h-screen flex items-center justify-center"
  31. style={{ background: 'var(--bg)' }}>
  32. <div className="flex flex-col items-center gap-4 animate-fade-in">
  33. <div className="w-11 h-11 rounded-xl flex items-center justify-center"
  34. style={{ background: '#6366F1', boxShadow: '0 0 28px rgba(99,102,241,0.5)' }}>
  35. <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  36. <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" />
  37. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  38. </svg>
  39. </div>
  40. <div className="flex items-center gap-2.5">
  41. <div className="w-4 h-4 rounded-full animate-spin"
  42. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  43. <span className="text-sm" style={{ color: 'var(--text-muted)' }}>Loading…</span>
  44. </div>
  45. </div>
  46. </div>
  47. );
  48. }
  49. if (!user) return null;
  50. const isActive = (href: string) =>
  51. href === '/' ? pathname === '/' : pathname.startsWith(href);
  52. const SidebarContent = () => (
  53. <>
  54. {/* Logo */}
  55. <div className="py-4 flex justify-center md:justify-start md:px-4"
  56. style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  57. <Link href="/projects" className="flex items-center gap-2.5 group">
  58. <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
  59. style={{ background: '#6366F1', boxShadow: '0 0 16px rgba(99,102,241,0.4)' }}>
  60. <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  61. <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" />
  62. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  63. </svg>
  64. </div>
  65. <span className="font-semibold tracking-tight text-sm hidden md:block" style={{ color: 'var(--text)' }}>
  66. VidReview
  67. </span>
  68. </Link>
  69. </div>
  70. {/* Nav */}
  71. <nav className="flex-1 px-3 py-4 overflow-y-auto">
  72. <NavSection label="Workspace">
  73. <NavLink
  74. href="/projects"
  75. active={isActive('/projects')}
  76. onClick={() => setSidebarOpen(false)}
  77. icon={
  78. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  79. <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" />
  80. </svg>
  81. }
  82. >
  83. Projects
  84. </NavLink>
  85. </NavSection>
  86. {/* Secondary links */}
  87. <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
  88. {user.globalRole === 'ADMIN' && (
  89. <NavLink
  90. href="/users"
  91. active={isActive('/users')}
  92. onClick={() => setSidebarOpen(false)}
  93. icon={
  94. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  95. <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" />
  96. </svg>
  97. }
  98. >
  99. Users
  100. </NavLink>
  101. )}
  102. <NavLink
  103. href="/settings"
  104. active={isActive('/settings')}
  105. onClick={() => setSidebarOpen(false)}
  106. icon={
  107. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  108. <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" />
  109. <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
  110. </svg>
  111. }
  112. >
  113. Settings
  114. </NavLink>
  115. </div>
  116. </nav>
  117. {/* User / logout */}
  118. <div className="py-3 px-3 shrink-0"
  119. style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
  120. {/* Storage quota bar */}
  121. {(user.storageQuota ?? 0) > 0 && (
  122. <div className="mb-2 px-1">
  123. <div className="flex items-center justify-between mb-1">
  124. <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>Storage</span>
  125. <span className="text-[10px] font-mono" style={{ color: 'var(--text-subtle)' }}>
  126. {formatBytes(user.storageUsed ?? 0)} / {formatBytes(user.storageQuota ?? 0)}
  127. </span>
  128. </div>
  129. <div className="h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  130. <div
  131. className="h-full rounded-full transition-all"
  132. style={{
  133. width: `${Math.min(100, ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) * 100)}%`,
  134. background: ((user.storageUsed ?? 0) / (user.storageQuota ?? 1)) > 0.85 ? '#EF4444' : '#6366F1',
  135. }}
  136. />
  137. </div>
  138. </div>
  139. )}
  140. <div className="flex items-center gap-2.5 p-2 rounded-lg"
  141. style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
  142. <Avatar name={user.name} src={user.avatarUrl} size="md" />
  143. <div className="flex-1 min-w-0 hidden md:block">
  144. <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
  145. <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
  146. {user.globalRole.toLowerCase()}
  147. </p>
  148. </div>
  149. <button
  150. onClick={async () => {
  151. await logout();
  152. router.push('/login');
  153. }}
  154. className="p-1.5 rounded-md transition-colors hover:bg-white/5"
  155. title="Sign out"
  156. >
  157. <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  158. <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" />
  159. </svg>
  160. </button>
  161. </div>
  162. </div>
  163. </>
  164. );
  165. return (
  166. <div className="min-h-screen flex" style={{ background: 'var(--bg)' }}>
  167. {/* ── Mobile hamburger button ─────────────────────────── */}
  168. <button
  169. onClick={() => setSidebarOpen(true)}
  170. className="fixed top-3 left-3 z-50 p-2 rounded-lg"
  171. style={{
  172. background: 'rgba(10,11,20,0.95)',
  173. border: '1px solid rgba(255,255,255,0.10)',
  174. backdropFilter: 'blur(8px)',
  175. color: 'var(--text-muted)',
  176. }}
  177. aria-label="Open menu"
  178. >
  179. <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  180. <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
  181. </svg>
  182. </button>
  183. {/* ── Mobile overlay backdrop ─────────────────────────── */}
  184. {sidebarOpen && (
  185. <div
  186. className="fixed inset-0 z-40 md:hidden"
  187. style={{ background: 'rgba(0,0,0,0.6)' }}
  188. onClick={() => setSidebarOpen(false)}
  189. />
  190. )}
  191. {/* ── Sidebar (drawer on mobile, fixed sidebar on desktop) ─ */}
  192. <aside
  193. className={`
  194. fixed inset-y-0 left-0 z-50 md:z-auto md:h-screen
  195. flex flex-col shrink-0 overflow-hidden
  196. transition-transform duration-200 ease-out md:transition-none
  197. w-56 md:w-56
  198. ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
  199. `}
  200. style={{
  201. background: 'rgba(10,11,20,0.97)',
  202. borderRight: '1px solid rgba(255,255,255,0.06)',
  203. backdropFilter: 'blur(16px)',
  204. }}
  205. >
  206. {/* Close button on mobile */}
  207. <button
  208. onClick={() => setSidebarOpen(false)}
  209. className="absolute top-3 right-3 p-1.5 rounded-lg md:hidden hover:bg-white/5"
  210. style={{ color: 'var(--text-muted)' }}
  211. aria-label="Close menu"
  212. >
  213. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  214. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  215. </svg>
  216. </button>
  217. <SidebarContent />
  218. </aside>
  219. {/* ── Main content (padding-left on desktop for fixed sidebar, padding-top on mobile for hamburger) ─── */}
  220. <main className="flex-1 overflow-auto min-w-0 md:ml-56 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
  221. {children}
  222. </main>
  223. </div>
  224. );
  225. }
  226. function NavSection({ label, children }: { label?: string; children: React.ReactNode }) {
  227. return (
  228. <div>
  229. {label && (
  230. <p className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-widest mb-1"
  231. style={{ color: 'var(--text-subtle)' }}>{label}</p>
  232. )}
  233. {children}
  234. </div>
  235. );
  236. }
  237. function NavLink({
  238. href,
  239. active,
  240. icon,
  241. children,
  242. onClick,
  243. }: {
  244. href: string;
  245. active: boolean;
  246. icon: React.ReactNode;
  247. children: React.ReactNode;
  248. onClick?: () => void;
  249. }) {
  250. return (
  251. <Link
  252. href={href}
  253. onClick={onClick}
  254. className={`nav-item mb-0.5${active ? ' nav-item-active' : ''}`}
  255. >
  256. <span className="shrink-0">{icon}</span>
  257. <span>{children}</span>
  258. </Link>
  259. );
  260. }