| <script> |
| import { onMount, onDestroy } from 'svelte'; |
| import { page } from '$app/stores'; |
| import { goto } from '$app/navigation'; |
| import { authStore, isAdmin, isFacultyOrAdmin, showToast } from '$lib/stores.js'; |
| import { t } from '$lib/i18n.js'; |
| |
| const MIN_W = 52; |
| const MAX_W = 360; |
| const DEFAULT_W = 220; |
| const COMPACT_THRESHOLD = 72; |
| |
| let sidebarWidth = DEFAULT_W; |
| let compact = false; |
| let resizerEl; |
| let dragging = false; |
| let dragStartX = 0; |
| let dragStartW = 0; |
| |
| $: path = $page.url.pathname; |
| $: compact = sidebarWidth < COMPACT_THRESHOLD; |
| |
| const navItems = [ |
| { href: '/chat', key: 'nav.chat', label: 'Chat' }, |
| { href: '/dashboard', key: 'nav.dashboard', label: 'Dashboard' }, |
| { href: '/notebooks', key: 'nav.notebooks', label: 'Notebooks' }, |
| { href: '/rag', key: 'nav.rag', label: 'Knowledge' }, |
| { href: '/doubts', key: 'nav.doubts', label: 'Doubts' }, |
| { href: '/attendance', key: 'nav.attendance', label: 'Attendance' }, |
| { href: '/files', key: 'nav.files', label: 'Files' }, |
| { href: '/notifications', key: 'nav.notifications', label: 'Notifications' }, |
| { href: '/keys', key: 'nav.keys', label: 'API Keys' }, |
| { href: '/settings', key: 'nav.settings', label: 'Settings' }, |
| ]; |
| |
| const adminItems = [ |
| { href: '/copy-check', key: 'nav.copycheck', label: 'Copy Check', faculty: true }, |
| { href: '/admin', key: 'nav.admin', label: 'Admin' }, |
| { href: '/cluster', key: 'nav.cluster', label: 'Cluster' }, |
| ]; |
| |
| |
| const icons = { |
| chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, |
| dashboard: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>`, |
| notebooks: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5V5a2 2 0 0 1 2-2h11a3 3 0 0 1 3 3v13.5"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M8 7h7"/><path d="M8 11h5"/></svg>`, |
| rag: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`, |
| doubts: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18h.01"/><path d="M9.1 9a3 3 0 1 1 5.2 2c-.9.8-1.8 1.3-2.1 2.7"/><path d="M21 12a9 9 0 1 1-4.2-7.6"/></svg>`, |
| attendance: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-8 0v2"/><circle cx="12" cy="7" r="4"/><path d="m17 11 2 2 4-4"/></svg>`, |
| files: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>`, |
| notifications: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`, |
| keys: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M21 2l-9.6 9.6"/><path d="M15.5 7.5l3 3L22 7l-3-3"/></svg>`, |
| settings: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`, |
| copycheck: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`, |
| admin: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L3 7l1.5 10L12 22l7.5-5L21 7z"/><circle cx="12" cy="12" r="3"/></svg>`, |
| cluster: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`, |
| logout: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`, |
| logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`, |
| }; |
| |
| const iconMap = { |
| '/chat': 'chat', |
| '/dashboard': 'dashboard', |
| '/notebooks': 'notebooks', |
| '/rag': 'rag', |
| '/doubts': 'doubts', |
| '/attendance': 'attendance', |
| '/files': 'files', |
| '/notifications': 'notifications', |
| '/keys': 'keys', |
| '/settings': 'settings', |
| '/copy-check': 'copycheck', |
| '/admin': 'admin', |
| '/cluster': 'cluster', |
| }; |
| |
| |
| function onMouseDown(e) { |
| dragging = true; |
| dragStartX = e.clientX; |
| dragStartW = sidebarWidth; |
| e.preventDefault(); |
| } |
| |
| function onMouseMove(e) { |
| if (!dragging) return; |
| const delta = e.clientX - dragStartX; |
| sidebarWidth = Math.min(MAX_W, Math.max(MIN_W, dragStartW + delta)); |
| } |
| |
| function onMouseUp() { |
| if (dragging) { |
| dragging = false; |
| try { localStorage.setItem('mac_sidebar_w', String(sidebarWidth)); } catch {} |
| } |
| } |
| |
| function onDblClick() { |
| sidebarWidth = compact ? DEFAULT_W : MIN_W; |
| try { localStorage.setItem('mac_sidebar_w', String(sidebarWidth)); } catch {} |
| } |
| |
| |
| function onTouchStart(e) { |
| dragging = true; |
| dragStartX = e.touches[0].clientX; |
| dragStartW = sidebarWidth; |
| } |
| |
| function onTouchMove(e) { |
| if (!dragging) return; |
| const delta = e.touches[0].clientX - dragStartX; |
| sidebarWidth = Math.min(MAX_W, Math.max(MIN_W, dragStartW + delta)); |
| e.preventDefault(); |
| } |
| |
| function onTouchEnd() { |
| if (dragging) { |
| dragging = false; |
| try { localStorage.setItem('mac_sidebar_w', String(sidebarWidth)); } catch {} |
| } |
| } |
| |
| async function logout() { |
| await authStore.logout(); |
| goto('/login'); |
| } |
| |
| onMount(() => { |
| try { |
| const saved = localStorage.getItem('mac_sidebar_w'); |
| if (saved) sidebarWidth = Math.min(MAX_W, Math.max(MIN_W, Number(saved))); |
| } catch {} |
| |
| window.addEventListener('mousemove', onMouseMove); |
| window.addEventListener('mouseup', onMouseUp); |
| }); |
| |
| onDestroy(() => { |
| window.removeEventListener('mousemove', onMouseMove); |
| window.removeEventListener('mouseup', onMouseUp); |
| }); |
| </script> |
|
|
| <aside |
| class="sidebar" |
| class:compact |
| class:dragging |
| style="width: {sidebarWidth}px; min-width: {sidebarWidth}px;" |
| > |
| <!-- Logo --> |
| <div class="logo-area"> |
| <span class="glitch sidebar-mac" data-text="MAC">MAC</span> |
| {#if !compact} |
| <span class="logo-sub">MBM AI Cloud</span> |
| {/if} |
| </div> |
|
|
| <!-- Nav --> |
| <nav class="nav-section"> |
| {#each navItems as item} |
| <a |
| href={item.href} |
| class="nav-link" |
| class:active={path.startsWith(item.href)} |
| title={compact ? ($t(item.key) || item.label) : ''} |
| > |
| <span class="nav-icon">{@html icons[iconMap[item.href]]}</span> |
| {#if !compact} |
| <span class="nav-label">{$t(item.key) || item.label}</span> |
| {/if} |
| </a> |
| {/each} |
|
|
| {#if $isFacultyOrAdmin} |
| {#if !compact} |
| <div class="section-header">Controls</div> |
| {:else} |
| <div class="section-divider"></div> |
| {/if} |
| {#each adminItems as item} |
| {#if item.faculty || $isAdmin} |
| <a |
| href={item.href} |
| class="nav-link" |
| class:active={path.startsWith(item.href)} |
| title={compact ? ($t(item.key) || item.label) : ''} |
| > |
| <span class="nav-icon">{@html icons[iconMap[item.href]]}</span> |
| {#if !compact} |
| <span class="nav-label">{$t(item.key) || item.label}</span> |
| {/if} |
| </a> |
| {/if} |
| {/each} |
| {/if} |
| </nav> |
|
|
| <!-- User + Logout --> |
| <div class="user-area"> |
| <div class="user-info" class:compact-user={compact}> |
| <div class="avatar"> |
| {$authStore.user?.name?.charAt(0)?.toUpperCase() ?? '?'} |
| </div> |
| {#if !compact} |
| <div class="user-text"> |
| <span class="user-name">{$authStore.user?.name ?? ''}</span> |
| <span class="user-role">{$authStore.user?.role ?? ''}</span> |
| </div> |
| {/if} |
| </div> |
| <button |
| class="nav-link logout-btn" |
| on:click={logout} |
| title={compact ? 'Logout' : ''} |
| > |
| <span class="nav-icon">{@html icons.logout}</span> |
| {#if !compact} |
| <span class="nav-label">Logout</span> |
| {/if} |
| </button> |
| </div> |
|
|
| <!-- Resize handle --> |
| <div |
| class="resizer" |
| bind:this={resizerEl} |
| on:mousedown={onMouseDown} |
| on:dblclick={onDblClick} |
| on:touchstart|passive={onTouchStart} |
| on:touchmove|preventDefault={onTouchMove} |
| on:touchend={onTouchEnd} |
| role="separator" |
| aria-label="Resize sidebar" |
| > |
| <div class="resizer-line"></div> |
| </div> |
| </aside> |
|
|
| <style> |
| .sidebar { |
| position: relative; |
| z-index: 2; |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| background: var(--surface); |
| border-right: 1px solid var(--border); |
| flex-shrink: 0; |
| transition: width 0.15s ease; |
| overflow: hidden; |
| user-select: none; |
| } |
| |
| .sidebar.dragging { |
| transition: none; |
| } |
| |
| |
| .logo-area { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 14px 14px 14px 14px; |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| overflow: hidden; |
| } |
| |
| |
| .sidebar-mac { |
| font-size: 16px; |
| flex-shrink: 0; |
| } |
| |
| .logo-sub { |
| font-size: 10px; |
| color: var(--text3); |
| white-space: nowrap; |
| margin-top: 1px; |
| } |
| |
| |
| .nav-section { |
| flex: 1; |
| padding: 8px 6px; |
| overflow-y: auto; |
| overflow-x: hidden; |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| } |
| |
| .nav-section::-webkit-scrollbar { width: 4px; } |
| .nav-section::-webkit-scrollbar-track { background: transparent; } |
| .nav-section::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; } |
| |
| .nav-link { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 8px 10px; |
| border-radius: 8px; |
| color: var(--text2); |
| font-size: 13px; |
| font-weight: 500; |
| text-decoration: none; |
| cursor: pointer; |
| background: none; |
| border: none; |
| width: 100%; |
| transition: background 0.15s, color 0.15s; |
| white-space: nowrap; |
| overflow: hidden; |
| } |
| |
| .nav-link:hover { |
| background: var(--surface2); |
| color: var(--text); |
| } |
| |
| .nav-link.active { |
| background: rgba(217,116,73,0.10); |
| color: var(--accent); |
| } |
| |
| .nav-link.active .nav-icon :global(svg) { |
| stroke: var(--accent); |
| } |
| |
| .nav-icon { |
| width: 18px; |
| height: 18px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| flex-shrink: 0; |
| } |
| |
| .nav-icon :global(svg) { |
| width: 18px; |
| height: 18px; |
| stroke: currentColor; |
| flex-shrink: 0; |
| } |
| |
| .nav-label { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| flex: 1; |
| } |
| |
| .section-header { |
| padding: 10px 12px 4px; |
| font-size: 10px; |
| color: var(--text3); |
| text-transform: uppercase; |
| letter-spacing: 0.12em; |
| font-weight: 600; |
| } |
| |
| .section-divider { |
| height: 1px; |
| background: var(--border); |
| margin: 6px 8px; |
| } |
| |
| |
| .compact .nav-link { |
| justify-content: center; |
| padding: 10px; |
| gap: 0; |
| } |
| |
| .compact .logo-area { |
| justify-content: center; |
| padding: 14px 10px; |
| } |
| |
| |
| .user-area { |
| border-top: 1px solid var(--border); |
| padding: 8px 6px; |
| flex-shrink: 0; |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| } |
| |
| .user-info { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 6px 10px 4px; |
| overflow: hidden; |
| } |
| |
| .user-info.compact-user { |
| justify-content: center; |
| padding: 6px 10px; |
| } |
| |
| .avatar { |
| width: 28px; |
| height: 28px; |
| border-radius: 50%; |
| background: rgba(217,116,73,0.15); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 11px; |
| font-weight: 700; |
| color: var(--accent); |
| flex-shrink: 0; |
| } |
| |
| .user-text { |
| display: flex; |
| flex-direction: column; |
| min-width: 0; |
| overflow: hidden; |
| } |
| |
| .user-name { |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--text); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .user-role { |
| font-size: 10px; |
| color: var(--text3); |
| text-transform: capitalize; |
| white-space: nowrap; |
| } |
| |
| .logout-btn { |
| color: var(--text3); |
| } |
| |
| .logout-btn:hover { |
| background: var(--error-bg); |
| color: var(--error); |
| } |
| |
| |
| .resizer { |
| position: absolute; |
| top: 0; |
| right: 0; |
| width: 8px; |
| height: 100%; |
| cursor: col-resize; |
| z-index: 10; |
| display: flex; |
| align-items: stretch; |
| justify-content: flex-end; |
| } |
| |
| .resizer-line { |
| width: 2px; |
| background: transparent; |
| transition: background 0.2s; |
| border-radius: 1px; |
| margin-right: 1px; |
| } |
| |
| .resizer:hover .resizer-line, |
| .dragging .resizer .resizer-line { |
| background: var(--accent); |
| } |
| </style> |
|
|