|
2 | 2 | const PATH = window.location.pathname || '/'; |
3 | 3 |
|
4 | 4 | function isActive(href) { |
5 | | - return PATH === href; |
| 5 | + return PATH === href || (href !== '/' && PATH.startsWith(href + '/')); |
6 | 6 | } |
7 | 7 |
|
8 | | - function navLink(href, label) { |
9 | | - const cls = 'nav-link' + (isActive(href) ? ' active' : ''); |
10 | | - return `<a href="${href}" class="${cls}">${label}</a>`; |
| 8 | + /** Small inline SVG icons (Open WebUI–style, neutral) */ |
| 9 | + function icon(name) { |
| 10 | + const common = ' xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"'; |
| 11 | + const svg = (path) => `<svg class="app-nav-icon-svg" viewBox="0 0 24 24"${common}>${path}</svg>`; |
| 12 | + const icons = { |
| 13 | + home: svg('<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>'), |
| 14 | + layout: svg('<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>'), |
| 15 | + message: svg('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'), |
| 16 | + folder: svg('<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>'), |
| 17 | + zap: svg('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'), |
| 18 | + book: svg('<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"/>'), |
| 19 | + database: svg('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>'), |
| 20 | + user: svg('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'), |
| 21 | + settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>'), |
| 22 | + sliders: svg('<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/>'), |
| 23 | + cpu: svg('<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="15" x2="23" y2="15"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="15" x2="4" y2="15"/>'), |
| 24 | + logOut: svg('<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"/>') |
| 25 | + }; |
| 26 | + return icons[name] || icons.home; |
11 | 27 | } |
12 | 28 |
|
13 | | - function buildHeader(appName, isAdmin) { |
14 | | - const systemMenu = isAdmin ? ` |
15 | | - <div class="nav-dropdown"> |
16 | | - <span class="nav-link nav-dropdown-toggle${PATH.startsWith('/config') || PATH.startsWith('/personality') || PATH.startsWith('/heartbeat') || PATH.startsWith('/agents') || PATH.startsWith('/pipelines') || PATH.startsWith('/autoagent') || PATH.startsWith('/users') || PATH.startsWith('/editor') ? ' has-active' : ''}">SYSTEM</span> |
17 | | - <div class="nav-dropdown-menu"> |
18 | | - ${navLink('/config', 'CONFIG')} |
19 | | - ${navLink('/personality', 'PERSONALITY')} |
20 | | - ${navLink('/heartbeat', 'HEARTBEAT')} |
21 | | - ${navLink('/agents', 'AGENTS')} |
22 | | - ${navLink('/pipelines', 'PIPELINES')} |
23 | | - ${navLink('/autoagent', 'AUTOAGENT')} |
24 | | - ${navLink('/users', 'USERS')} |
25 | | - ${navLink('/editor', 'EDITOR')} |
26 | | - </div> |
| 29 | + function navItem(href, label, icn) { |
| 30 | + const active = isActive(href); |
| 31 | + const cls = 'app-nav-item' + (active ? ' active' : ''); |
| 32 | + const Tag = active ? 'span' : 'a'; |
| 33 | + const inner = `${icon(icn)}<span class="app-nav-label">${label}</span>`; |
| 34 | + if (active) return `<span class="${cls}" aria-current="page">${inner}</span>`; |
| 35 | + return `<a href="${href}" class="${cls}">${inner}</a>`; |
| 36 | + } |
| 37 | + |
| 38 | + function systemPathActive() { |
| 39 | + return ['/config', '/personality', '/heartbeat', '/agents', '/pipelines', '/autoagent', '/users', '/editor'].some(p => PATH === p || PATH.startsWith(p + '/')); |
| 40 | + } |
| 41 | + |
| 42 | + function buildSidebar(appName, isAdmin) { |
| 43 | + const sysOpen = systemPathActive(); |
| 44 | + const systemBlock = isAdmin ? ` |
| 45 | + <div class="app-nav-section"> |
| 46 | + <div class="app-nav-section-label">Administration</div> |
| 47 | + <details class="app-nav-details" ${sysOpen ? 'open' : ''}> |
| 48 | + <summary class="app-nav-details-summary"> |
| 49 | + ${icon('settings')} |
| 50 | + <span class="app-nav-label">System</span> |
| 51 | + <span class="app-nav-chevron" aria-hidden="true"></span> |
| 52 | + </summary> |
| 53 | + <div class="app-nav-sub"> |
| 54 | + ${navItem('/config', 'Config', 'sliders')} |
| 55 | + ${navItem('/personality', 'Personality', 'user')} |
| 56 | + ${navItem('/heartbeat', 'Heartbeat', 'cpu')} |
| 57 | + ${navItem('/agents', 'Agents', 'cpu')} |
| 58 | + ${navItem('/pipelines', 'Pipelines', 'layout')} |
| 59 | + ${navItem('/autoagent', 'AutoAgent', 'zap')} |
| 60 | + ${navItem('/users', 'Users', 'user')} |
| 61 | + ${navItem('/editor', 'Editor', 'layout')} |
| 62 | + </div> |
| 63 | + </details> |
27 | 64 | </div>` : ''; |
28 | 65 |
|
29 | 66 | return ` |
30 | | - <header class="app-header"> |
31 | | - <div class="header-left"> |
32 | | - <a href="/dashboard" class="logo">${appName}</a> |
33 | | - </div> |
34 | | - <nav class="header-nav"> |
35 | | - ${navLink('/dashboard', 'DASHBOARD')} |
36 | | - ${navLink('/command-center', 'COMMAND CENTER')} |
37 | | - ${navLink('/app', 'CHAT')} |
38 | | - ${navLink('/projects', 'PROJECTS')} |
39 | | - ${navLink('/skills', 'SKILLS')} |
40 | | - ${navLink('/rag', 'KNOWLEDGE')} |
41 | | - ${systemMenu} |
42 | | - ${navLink('/my-data', 'MY DATA')} |
43 | | - ${navLink('/profile', 'ACCOUNT')} |
44 | | - <button type="button" id="logoutBtn" class="btn btn-small">LOGOUT</button> |
45 | | - </nav> |
46 | | - </header> |
| 67 | + <div class="app-sidebar-brand"> |
| 68 | + <a href="/dashboard" class="app-sidebar-logo">${appName.replace(/_/g, ' ')}</a> |
| 69 | + </div> |
| 70 | + <nav class="app-sidebar-nav" aria-label="Main"> |
| 71 | + ${navItem('/dashboard', 'Dashboard', 'home')} |
| 72 | + ${navItem('/command-center', 'Command Center', 'layout')} |
| 73 | + ${navItem('/app', 'Chat', 'message')} |
| 74 | + ${navItem('/projects', 'Projects', 'folder')} |
| 75 | + ${navItem('/skills', 'Skills', 'zap')} |
| 76 | + ${navItem('/rag', 'Knowledge', 'book')} |
| 77 | + ${navItem('/my-data', 'My Data', 'database')} |
| 78 | + </nav> |
| 79 | + ${systemBlock} |
| 80 | + <div class="app-sidebar-footer"> |
| 81 | + ${navItem('/profile', 'Account', 'user')} |
| 82 | + <button type="button" class="app-nav-item app-nav-logout" id="logoutBtn"> |
| 83 | + ${icon('logOut')} |
| 84 | + <span class="app-nav-label">Logout</span> |
| 85 | + </button> |
| 86 | + </div> |
47 | 87 | `; |
48 | 88 | } |
49 | 89 |
|
50 | | - function wireInteractions() { |
51 | | - const btn = document.getElementById('logoutBtn'); |
52 | | - if (btn) btn.addEventListener('click', async () => { |
53 | | - await fetch('/api/logout', { method: 'POST' }); |
54 | | - window.location.href = '/login'; |
55 | | - }); |
| 90 | + function pageTitleFromDocument() { |
| 91 | + const t = document.title || ''; |
| 92 | + const m = t.match(/[—\-]\s*(.+)$/); |
| 93 | + return (m && m[1] ? m[1].trim() : t.replace(/^ShadowAI\s*[—\-]?\s*/i, '').trim()) || 'Shadow'; |
| 94 | + } |
| 95 | + |
| 96 | + function mountOpenWebUILayout(appName, isAdmin) { |
| 97 | + const header = document.querySelector('.app-header'); |
| 98 | + if (!header) return false; |
56 | 99 |
|
57 | | - const appHeader = document.querySelector('.app-header'); |
58 | | - const headerNav = document.querySelector('.header-nav'); |
59 | | - if (!appHeader || !headerNav) return; |
60 | | - |
61 | | - const hamburger = document.createElement('button'); |
62 | | - hamburger.className = 'nav-hamburger'; |
63 | | - hamburger.setAttribute('aria-label', 'Toggle navigation'); |
64 | | - hamburger.setAttribute('aria-expanded', 'false'); |
65 | | - hamburger.textContent = '\u2630'; |
66 | | - appHeader.insertBefore(hamburger, headerNav); |
67 | | - |
68 | | - function closeHamburgerNav() { |
69 | | - headerNav.classList.remove('nav-open'); |
70 | | - hamburger.setAttribute('aria-expanded', 'false'); |
71 | | - hamburger.textContent = '\u2630'; |
| 100 | + let main = document.querySelector('main'); |
| 101 | + if (!main) { |
| 102 | + const toWrap = []; |
| 103 | + let n = header.nextElementSibling; |
| 104 | + while (n && n.tagName !== 'SCRIPT') { |
| 105 | + const next = n.nextElementSibling; |
| 106 | + toWrap.push(n); |
| 107 | + n = next; |
| 108 | + } |
| 109 | + if (!toWrap.length) return false; |
| 110 | + main = document.createElement('main'); |
| 111 | + main.className = 'app-fallback-main'; |
| 112 | + toWrap.forEach((el) => main.appendChild(el)); |
72 | 113 | } |
73 | 114 |
|
74 | | - hamburger.addEventListener('click', function (e) { |
75 | | - e.stopPropagation(); |
76 | | - const isOpen = headerNav.classList.toggle('nav-open'); |
77 | | - hamburger.setAttribute('aria-expanded', String(isOpen)); |
78 | | - hamburger.textContent = isOpen ? '\u2715' : '\u2630'; |
79 | | - }); |
| 115 | + document.body.classList.add('has-app-shell'); |
| 116 | + |
| 117 | + const extras = []; |
| 118 | + const agentSelect = document.getElementById('agentSelect'); |
| 119 | + const clearBtn = document.getElementById('clearChatBtn'); |
| 120 | + const exportBtn = document.getElementById('exportChatBtn'); |
| 121 | + if (agentSelect) extras.push(agentSelect); |
| 122 | + if (clearBtn) extras.push(clearBtn); |
| 123 | + if (exportBtn) extras.push(exportBtn); |
| 124 | + |
| 125 | + const shell = document.createElement('div'); |
| 126 | + shell.className = 'app-shell'; |
| 127 | + |
| 128 | + const sidebar = document.createElement('aside'); |
| 129 | + sidebar.className = 'app-sidebar'; |
| 130 | + sidebar.setAttribute('aria-label', 'Application'); |
| 131 | + sidebar.innerHTML = buildSidebar(appName, isAdmin); |
| 132 | + |
| 133 | + const wrap = document.createElement('div'); |
| 134 | + wrap.className = 'app-main-wrap'; |
| 135 | + |
| 136 | + const topbar = document.createElement('header'); |
| 137 | + topbar.className = 'app-topbar'; |
| 138 | + topbar.innerHTML = ` |
| 139 | + <button type="button" class="app-sidebar-toggle" id="appSidebarToggle" aria-label="Toggle sidebar" aria-expanded="true"> |
| 140 | + <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg> |
| 141 | + </button> |
| 142 | + <h1 class="app-topbar-title">${pageTitleFromDocument()}</h1> |
| 143 | + <div class="app-topbar-actions" id="appTopbarActions"></div> |
| 144 | + `; |
| 145 | + |
| 146 | + const actions = topbar.querySelector('#appTopbarActions'); |
| 147 | + extras.forEach((el) => actions.appendChild(el)); |
80 | 148 |
|
81 | | - headerNav.addEventListener('click', function (e) { |
82 | | - if (e.target.closest('a.nav-link')) closeHamburgerNav(); |
| 149 | + header.remove(); |
| 150 | + |
| 151 | + shell.appendChild(sidebar); |
| 152 | + shell.appendChild(wrap); |
| 153 | + wrap.appendChild(topbar); |
| 154 | + wrap.appendChild(main); |
| 155 | + |
| 156 | + document.body.insertBefore(shell, document.body.firstChild); |
| 157 | + return true; |
| 158 | + } |
| 159 | + |
| 160 | + function wireSidebarToggle() { |
| 161 | + const toggle = document.getElementById('appSidebarToggle'); |
| 162 | + const shell = document.querySelector('.app-shell'); |
| 163 | + if (!toggle || !shell) return; |
| 164 | + |
| 165 | + const mq = window.matchMedia('(max-width: 900px)'); |
| 166 | + function sync() { |
| 167 | + if (mq.matches) { |
| 168 | + shell.classList.remove('sidebar-collapsed'); |
| 169 | + shell.classList.remove('sidebar-overlay-open'); |
| 170 | + toggle.setAttribute('aria-expanded', 'false'); |
| 171 | + } else { |
| 172 | + shell.classList.remove('sidebar-overlay-open'); |
| 173 | + toggle.setAttribute('aria-expanded', 'true'); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + toggle.addEventListener('click', () => { |
| 178 | + if (mq.matches) { |
| 179 | + shell.classList.toggle('sidebar-overlay-open'); |
| 180 | + toggle.setAttribute('aria-expanded', String(shell.classList.contains('sidebar-overlay-open'))); |
| 181 | + } else { |
| 182 | + shell.classList.toggle('sidebar-collapsed'); |
| 183 | + const collapsed = shell.classList.contains('sidebar-collapsed'); |
| 184 | + toggle.setAttribute('aria-expanded', String(!collapsed)); |
| 185 | + } |
83 | 186 | }); |
84 | 187 |
|
85 | | - document.addEventListener('click', function (e) { |
86 | | - if (!e.target.closest('.app-header')) closeHamburgerNav(); |
| 188 | + document.addEventListener('click', (e) => { |
| 189 | + if (!mq.matches) return; |
| 190 | + if (!shell.classList.contains('sidebar-overlay-open')) return; |
| 191 | + if (e.target.closest('.app-sidebar') || e.target.closest('#appSidebarToggle')) return; |
| 192 | + shell.classList.remove('sidebar-overlay-open'); |
| 193 | + toggle.setAttribute('aria-expanded', 'false'); |
87 | 194 | }); |
88 | 195 |
|
89 | | - document.querySelectorAll('.nav-dropdown-toggle').forEach(function (toggle) { |
90 | | - toggle.addEventListener('click', function (e) { |
91 | | - e.stopPropagation(); |
92 | | - toggle.closest('.nav-dropdown').classList.toggle('open'); |
| 196 | + mq.addEventListener('change', sync); |
| 197 | + sync(); |
| 198 | + } |
| 199 | + |
| 200 | + function wireDetailsCloseOnNavigate() { |
| 201 | + document.querySelectorAll('.app-sidebar a.app-nav-item').forEach((a) => { |
| 202 | + a.addEventListener('click', () => { |
| 203 | + document.querySelector('.app-shell')?.classList.remove('sidebar-overlay-open'); |
93 | 204 | }); |
94 | 205 | }); |
95 | | - document.addEventListener('click', function (e) { |
96 | | - if (!e.target.closest('.nav-dropdown')) { |
97 | | - document.querySelectorAll('.nav-dropdown.open').forEach(function (d) { d.classList.remove('open'); }); |
98 | | - } |
| 206 | + } |
| 207 | + |
| 208 | + function wireLogout() { |
| 209 | + const btn = document.getElementById('logoutBtn'); |
| 210 | + if (!btn) return; |
| 211 | + btn.addEventListener('click', async () => { |
| 212 | + await fetch('/api/logout', { method: 'POST' }); |
| 213 | + window.location.href = '/login'; |
99 | 214 | }); |
100 | 215 | } |
101 | 216 |
|
|
105 | 220 | ]).then(([me, app]) => { |
106 | 221 | const appName = (app && app.appName && String(app.appName).trim()) || 'SHADOW_AI'; |
107 | 222 | const isAdmin = !!(me && me.role === 'admin'); |
108 | | - const existing = document.querySelector('.app-header'); |
109 | | - if (existing) existing.outerHTML = buildHeader(appName, isAdmin); |
110 | | - else document.body.insertAdjacentHTML('afterbegin', buildHeader(appName, isAdmin)); |
111 | 223 |
|
112 | 224 | if (document.title && document.title.indexOf('ShadowAI') !== -1) { |
113 | 225 | document.title = document.title.replace(/ShadowAI/g, appName.replace(/_/g, ' ')); |
114 | 226 | } |
115 | | - wireInteractions(); |
| 227 | + |
| 228 | + const mounted = mountOpenWebUILayout(appName, isAdmin); |
| 229 | + if (!mounted) return; |
| 230 | + |
| 231 | + wireLogout(); |
| 232 | + wireSidebarToggle(); |
| 233 | + wireDetailsCloseOnNavigate(); |
116 | 234 | }); |
117 | 235 | })(); |
0 commit comments