diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 70416f1a..3d388e25 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -42,13 +42,17 @@ --warning: var(--amber-500); --danger: var(--red-500); - /* Sidebar (Always Dark) */ - --sidebar-bg: var(--slate-800); - --sidebar-fg: var(--slate-200); - --sidebar-hover: rgba(255, 255, 255, 0.1); - --sidebar-border: rgba(255, 255, 255, 0.1); + /* Sidebar — Light mode */ + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); --sidebar-width: 240px; + /* Login page */ + --dot-color: rgba(0, 0, 0, 0.055); + /* Layout */ --radius: 8px; --radius-sm: 4px; @@ -70,6 +74,12 @@ --fg-muted: var(--slate-400); --border: var(--slate-700); --border-subtle: var(--slate-800); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); } } @@ -82,6 +92,12 @@ --border: var(--slate-700); --border-subtle: var(--slate-800); --zebra-stripe: rgba(255,255,255,0.03); + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-hover-fg: #fff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --dot-color: rgba(255, 255, 255, 0.045); } /* Force Light Mode (override system dark preference) */ @@ -92,6 +108,12 @@ --fg-muted: var(--slate-500); --border: var(--slate-200); --border-subtle: var(--slate-100); + --sidebar-bg: var(--slate-100); + --sidebar-fg: var(--slate-700); + --sidebar-hover: rgba(0, 0, 0, 0.07); + --sidebar-hover-fg: var(--primary); + --sidebar-border: rgba(0, 0, 0, 0.08); + --dot-color: rgba(0, 0, 0, 0.055); } .light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; } .light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; } @@ -180,7 +202,6 @@ a:hover { .sidebar-header { padding: 1.5rem 1.25rem; - border-bottom: 1px solid var(--sidebar-border); } .sidebar-logo { @@ -195,45 +216,58 @@ a:hover { overflow-y: auto; } -/* Accordion Nav Groups */ -details.nav-group { - border-top: 1px solid var(--sidebar-border); -} -details.nav-group:first-child { - border-top: none; +/* Standalone nav link (Dashboard) */ +.nav-standalone { + margin: 0.25rem 0.5rem 0.5rem; } -details.nav-group > summary.nav-group-summary { +/* Top-level accordion groups (Status / Configure / Maintenance) */ +details.nav-group-top { +} + +details.nav-group-top > summary.nav-group-summary-top { list-style: none; cursor: pointer; - font-size: 0.65rem; - font-weight: 600; - letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.07em; text-transform: uppercase; color: var(--sidebar-fg); - opacity: 0.5; - padding: 0.875rem 1rem 0.375rem; + opacity: 0.7; + padding: 0.75rem 1rem 0.375rem; display: flex; justify-content: space-between; align-items: center; user-select: none; transition: opacity 0.15s; } -details.nav-group > summary.nav-group-summary:hover { opacity: 0.8; } -details.nav-group > summary.nav-group-summary::-webkit-details-marker { display: none; } -details.nav-group > summary.nav-group-summary::marker { display: none; } +details.nav-group-top > summary.nav-group-summary-top:hover { opacity: 1; } +details.nav-group-top > summary.nav-group-summary-top::-webkit-details-marker { display: none; } +details.nav-group-top > summary.nav-group-summary-top::marker { display: none; } -details.nav-group > summary.nav-group-summary::after { +details.nav-group-top > summary.nav-group-summary-top::after { content: '▸'; font-size: 0.7rem; } -details[open].nav-group > summary.nav-group-summary::after { +details[open].nav-group-top > summary.nav-group-summary-top::after { content: '▾'; } +/* Static sub-section labels within top-level accordion */ +.nav-section-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sidebar-fg); + opacity: 0.45; + padding: 0.75rem 1rem 0.2rem; + user-select: none; +} + .nav-group-items { list-style: none; - padding-bottom: 0.5rem; + padding-bottom: 0.25rem; } .nav-icon { @@ -262,13 +296,13 @@ details[open].nav-group > summary.nav-group-summary::after { font-size: 0.875rem; border-radius: 0.375rem; margin: 0 0.5rem; - transition: background-color 0.15s; + transition: background-color 0.15s, color 0.15s; font-weight: 500; } .nav-link:hover { background-color: var(--sidebar-hover); - color: #fff; + color: var(--sidebar-hover-fg); text-decoration: none; } @@ -278,6 +312,22 @@ details[open].nav-group > summary.nav-group-summary::after { text-decoration: none; } +/* Button styled as a nav-link (for actions like Reboot) */ +.nav-link-btn { + width: 100%; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: inherit; + font-size: inherit; +} + +.nav-link-danger:hover { + color: var(--danger); + background-color: var(--sidebar-hover); +} + .sidebar-footer { padding: 1.25rem; border-top: 1px solid var(--sidebar-border); @@ -521,12 +571,19 @@ details[open].nav-group > summary.nav-group-summary::after { Authentication ========================================================================== */ +@keyframes login-rise { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + .login-wrapper { display: flex; align-items: center; justify-content: center; min-height: 100vh; - background: var(--bg); + background-color: var(--bg); + background-image: radial-gradient(var(--dot-color) 1.5px, transparent 1.5px); + background-size: 22px 22px; padding: 1rem; } @@ -534,10 +591,12 @@ details[open].nav-group > summary.nav-group-summary::after { background: var(--surface); padding: 2.5rem; border-radius: var(--radius); - box-shadow: var(--shadow); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07), + 0 0 0 1px var(--border); width: 100%; max-width: 400px; - border: 1px solid var(--border); + border-top: 2px solid var(--primary); + animation: login-rise 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; } .login-logo { @@ -551,6 +610,19 @@ details[open].nav-group > summary.nav-group-summary::after { Alerts & Banners ========================================================================== */ +/* Top progress bar — shown during htmx page navigations */ +.page-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background: var(--primary); + z-index: 9999; + opacity: 0; + pointer-events: none; +} + .conn-banner { background: var(--danger); color: #fff; @@ -1023,8 +1095,8 @@ details[open].nav-group > summary.nav-group-summary::after { justify-content: flex-end; padding: 0 1rem; height: 48px; - background: var(--surface); - border-bottom: 1px solid var(--border); + background: var(--sidebar-bg); + border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; z-index: 100; } @@ -1139,7 +1211,7 @@ details[open].nav-group > summary.nav-group-summary::after { } .dropdown-item:hover, .dropdown-item:focus { - background: var(--border-subtle); + background: var(--border); color: var(--fg); text-decoration: none; } @@ -1174,20 +1246,28 @@ details[open].nav-group > summary.nav-group-summary::after { } .login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); } -/* Login lock icon */ -.login-icon { +.login-header { display: flex; + align-items: center; justify-content: center; - margin-bottom: 1.5rem; + gap: 0.5rem; + margin-bottom: 1.75rem; color: var(--fg-muted); } +.login-header h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--fg); + line-height: 1; +} + /* ========================================================================== Responsive Layout ========================================================================== */ -/* Mobile (≤768px): Sidebar hidden, hamburger visible */ -@media (max-width: 768px) { +/* Narrow (≤1024px): Sidebar hidden behind hamburger overlay */ +@media (max-width: 1024px) { #sidebar { position: fixed; top: 0; @@ -1221,43 +1301,8 @@ details[open].nav-group > summary.nav-group-summary::after { } } -/* Tablet (769px–1024px): Icon-only sidebar */ -@media (min-width: 769px) and (max-width: 1024px) { - :root { - --sidebar-width: 64px; - } - - .nav-link span:not(.nav-icon) { - display: none; - } - - /* Accordion in icon-only mode: always show items, hide section headers */ - details.nav-group > summary.nav-group-summary { - display: none; - } - details.nav-group:not([open]) > .nav-group-items { - display: block !important; - } - - .nav-link { - justify-content: center; - padding: 0.6rem; - } - - .sidebar-header { - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - } - - .sidebar-logo { - max-width: 32px; - } -} - -/* Desktop: hamburger hidden */ -@media (min-width: 769px) { +/* Desktop (>1024px): hamburger hidden, sidebar always visible */ +@media (min-width: 1025px) { .hamburger-btn { display: none; } diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index fe69109a..845397da 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -135,6 +135,93 @@ } })(); +// Active nav link — keep in sync with the current URL after htmx navigation. +// Also opens the top-level accordion section containing the active link, +// and immediately highlights the clicked link (optimistic active state). +(function() { + function updateActiveNav() { + var path = window.location.pathname; + var activeTopGroup = null; + + document.querySelectorAll('.nav-link').forEach(function(link) { + var href = link.getAttribute('href'); + var active = href === path; + link.classList.toggle('active', active); + if (active) { + var topGroup = link.closest('details.nav-group-top'); + if (topGroup) activeTopGroup = topGroup; + } + }); + + if (activeTopGroup) { + document.querySelectorAll('details.nav-group-top').forEach(function(d) { + if (d === activeTopGroup) { + d.setAttribute('open', ''); + } else { + d.removeAttribute('open'); + } + }); + } + } + + // Optimistic active state: highlight the link immediately on click so the + // sidebar doesn't lag while waiting for the server to respond. + document.addEventListener('click', function(e) { + var link = e.target.closest('a.nav-link[hx-get]'); + if (!link) return; + document.querySelectorAll('.nav-link').forEach(function(l) { l.classList.remove('active'); }); + link.classList.add('active'); + }); + + document.addEventListener('DOMContentLoaded', updateActiveNav); + document.addEventListener('htmx:pushedIntoHistory', updateActiveNav); + window.addEventListener('popstate', updateActiveNav); +})(); + +// Top progress bar during htmx navigations +(function() { + var bar, timer; + + function getBar() { + if (!bar) bar = document.getElementById('page-progress'); + return bar; + } + + function start() { + var b = getBar(); + if (!b) return; + if (timer) { clearTimeout(timer); timer = null; } + b.style.transition = 'none'; + b.style.width = '0%'; + b.style.opacity = '1'; + b.offsetWidth; // force reflow so the transition below fires from 0 + b.style.transition = 'width 8s cubic-bezier(0.05, 0.8, 0.4, 1)'; + b.style.width = '85%'; + } + + function finish() { + var b = getBar(); + if (!b) return; + if (timer) clearTimeout(timer); + b.style.transition = 'width 0.1s ease'; + b.style.width = '100%'; + timer = setTimeout(function() { + b.style.transition = 'opacity 0.25s ease'; + b.style.opacity = '0'; + timer = setTimeout(function() { + b.style.transition = 'none'; + b.style.width = '0%'; + timer = null; + }, 260); + }, 120); + } + + document.addEventListener('htmx:beforeSend', start); + document.addEventListener('htmx:afterSettle', finish); + document.addEventListener('htmx:responseError', finish); + document.addEventListener('htmx:sendError', finish); +})(); + // Theme (auto / light / dark) — shared by main app and login page (function() { function getTheme() { @@ -205,67 +292,127 @@ })(); // Accordion nav group persistence +// Top-level sections (Status / Configure / Maintenance) are mutually exclusive. +// State is persisted in localStorage under 'nav-top:Name' keys. (function() { document.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('details.nav-group').forEach(function(d) { - var label = d.querySelector('summary'); + var groups = document.querySelectorAll('details.nav-group-top'); + + // Restore saved state (HTML default: Status open, others closed) + groups.forEach(function(d) { + var label = d.querySelector(':scope > summary'); if (!label) return; - var key = 'nav-group:' + label.textContent.trim(); + var key = 'nav-top:' + label.textContent.trim(); var saved = localStorage.getItem(key); - if (saved === 'closed') { - d.removeAttribute('open'); - } else if (saved === 'open') { + if (saved === 'open') { d.setAttribute('open', ''); + } else if (saved === 'closed') { + d.removeAttribute('open'); } + }); + + // Mutual exclusion, persistence, and auto-navigation to first page link + groups.forEach(function(d) { + var label = d.querySelector(':scope > summary'); + if (!label) return; + var key = 'nav-top:' + label.textContent.trim(); d.addEventListener('toggle', function() { localStorage.setItem(key, d.open ? 'open' : 'closed'); + if (d.open) { + groups.forEach(function(other) { + if (other !== d && other.open) { + other.removeAttribute('open'); + var otherLabel = other.querySelector(':scope > summary'); + if (otherLabel) { + localStorage.setItem('nav-top:' + otherLabel.textContent.trim(), 'closed'); + } + } + }); + // Navigate to the first page link if none in this section is active + if (!d.querySelector('.nav-link.active')) { + var first = d.querySelector('a.nav-link[hx-get]'); + if (first) first.click(); + } + } }); }); }); })(); -// User menu — hover handled by CSS; JS manages aria-expanded and keyboard +// User menu — hover (desktop) + click-toggle (touch) + keyboard (function() { document.addEventListener('DOMContentLoaded', function() { var menu = document.getElementById('user-menu'); var btn = document.getElementById('user-menu-btn'); if (!menu || !btn) return; - menu.addEventListener('mouseenter', function() { - btn.setAttribute('aria-expanded', 'true'); + function setExpanded(val) { + btn.setAttribute('aria-expanded', val ? 'true' : 'false'); + } + + // Hover: keep aria-expanded in sync so CSS transition fires correctly + menu.addEventListener('mouseenter', function() { setExpanded(true); }); + menu.addEventListener('mouseleave', function() { setExpanded(false); }); + + // Click/tap: toggle (primary interaction on touch devices) + btn.addEventListener('click', function(e) { + e.stopPropagation(); + setExpanded(btn.getAttribute('aria-expanded') !== 'true'); }); - menu.addEventListener('mouseleave', function() { - btn.setAttribute('aria-expanded', 'false'); + + // Close when clicking outside (touch: tap outside) + document.addEventListener('click', function(e) { + if (!menu.contains(e.target)) setExpanded(false); }); + document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') btn.setAttribute('aria-expanded', 'false'); + if (e.key === 'Escape') setExpanded(false); }); }); })(); // Sidebar toggle (mobile) (function() { + var MOBILE_BP = 1024; + + function closeSidebar(btn) { + document.body.classList.remove('sidebar-open'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + document.addEventListener('DOMContentLoaded', function() { var btn = document.getElementById('hamburger-btn'); if (!btn) return; + + // Open/close on hamburger click btn.addEventListener('click', function() { var open = document.body.classList.toggle('sidebar-open'); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); }); - // Close sidebar when clicking the overlay (::after pseudo) + + // Close when clicking the overlay (::after pseudo-element covers body) document.body.addEventListener('click', function(e) { if (document.body.classList.contains('sidebar-open') && e.target === document.body) { - document.body.classList.remove('sidebar-open'); - btn.setAttribute('aria-expanded', 'false'); + closeSidebar(btn); } }); - // Close sidebar when a nav link is clicked (htmx navigation) + + // Close when navigating via htmx document.addEventListener('htmx:beforeRequest', function() { - if (document.body.classList.contains('sidebar-open')) { - document.body.classList.remove('sidebar-open'); - if (btn) btn.setAttribute('aria-expanded', 'false'); - } + closeSidebar(btn); }); + + // Close when viewport grows beyond the mobile breakpoint so the + // sidebar-open class and overlay don't linger at desktop widths. + var mq = window.matchMedia('(max-width: ' + MOBILE_BP + 'px)'); + function onBreakpointChange(e) { + if (!e.matches) closeSidebar(btn); + } + if (mq.addEventListener) { + mq.addEventListener('change', onBreakpointChange); + } else { + mq.addListener(onBreakpointChange); // Safari <14 fallback + } }); })(); @@ -295,3 +442,26 @@ } document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible); })(); + +// ─── Login page: progress bar on submit ──────────────────────────────────── +// The login form is a native POST (not HTMX), so htmx:beforeSend never fires. +// Start the bar and disable the button to show the user something is happening. +(function() { + document.addEventListener('DOMContentLoaded', function() { + var form = document.querySelector('form[action="/login"]'); + if (!form) return; + form.addEventListener('submit', function() { + var bar = document.getElementById('page-progress'); + if (bar) { + bar.style.transition = 'none'; + bar.style.width = '0%'; + bar.style.opacity = '1'; + bar.offsetWidth; + bar.style.transition = 'width 8s cubic-bezier(0.05, 0.8, 0.4, 1)'; + bar.style.width = '85%'; + } + var btn = form.querySelector('button[type="submit"]'); + if (btn) { btn.disabled = true; btn.textContent = 'Logging in\u2026'; } + }); + }); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 18037c2b..3fb0cab7 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -14,6 +14,7 @@ +
{{template "sidebar" .}} @@ -56,19 +57,6 @@ - - - Download Config - - -