diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index f8d7435..b96f236 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -1,7 +1,15 @@ // Side navigation component. -// Reads items from navigation.json (path relative to web root via data-base attribute, -// defaults to ./navigation.json). -// Highlights the active entry by matching href against window.location.pathname. +// Desktop : sidebar fixe à gauche dans le flux flex. +// Mobile (≤768px) : overlay depuis la gauche, déclenché par un bouton hamburger +// inséré dans
. Géométrie gérée en inline style pour +// éviter les conflits de spécificité avec le shadow DOM. + +const HAMBURGER_ICON = ` + + + +` const ICONS = { dashboard: ``, @@ -10,90 +18,209 @@ const ICONS = { vm: ``, } +const MQ = window.matchMedia('(max-width: 768px)') +const NAV_W = 220 // px + function resolveIcon(name) { return ICONS[name] ?? `` } class SideNav extends HTMLElement { + #items = [] + #hamburger = null + #backdrop = null + #mqHandler = null + #isMobile = false + async connectedCallback() { this.attachShadow({ mode: 'open' }) - const navPath = this.dataset.src ?? './navigation.json' + const navPath = this.dataset.src ?? './navigation.json' const response = await fetch(navPath) if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`) - const items = await response.json() + this.#items = await response.json() - this.#render(items) + this.#render() + + this.#mqHandler = () => this.#applyMode() + MQ.addEventListener('change', this.#mqHandler) + this.#applyMode() } - #render(items) { - const currentPage = window.location.pathname.split('/').pop() || 'index.html' + disconnectedCallback() { + MQ.removeEventListener('change', this.#mqHandler) + this.#removeHamburger() + this.#removeBackdrop() + } - const links = items.map(item => { - const isActive = item.href === currentPage - return ` - - ${resolveIcon(item.icon)} - ${item.label} - - ` + // ── Rendu des items ───────────────────────────────────────────────────────── + + #render() { + const current = window.location.pathname.split('/').pop() || 'index.html' + + const links = this.#items.map(item => { + const active = item.href === current + return ` + ${resolveIcon(item.icon)} + ${item.label} + ` }).join('') this.shadowRoot.innerHTML = ` - ${links} ` + + // Fermer l'overlay sur tap d'un lien (mobile) + this.shadowRoot.querySelectorAll('.item').forEach(a => + a.addEventListener('click', () => { if (this.#isMobile) this.#close() }) + ) + } + + // ── Basculement desktop / mobile ──────────────────────────────────────────── + + #applyMode() { + if (MQ.matches) this.#toMobile() + else this.#toDesktop() + } + + #toMobile() { + this.#isMobile = true + + // inline style = spécificité maximale, pas de conflit avec le shadow DOM + Object.assign(this.style, { + position: 'fixed', + top: '0', + left: '0', + width: `${NAV_W}px`, + minWidth: `${NAV_W}px`, + height: '100vh', + zIndex: '500', + transform: 'translateX(-100%)', + transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)', + overflow: 'hidden', + boxShadow: '4px 0 24px rgba(0,0,0,0.45)', + }) + + this.#addHamburger() + } + + #toDesktop() { + this.#isMobile = false + // Réinitialise tous les styles inline → shadow DOM reprend le contrôle + this.style.cssText = '' + this.#removeHamburger() + this.#removeBackdrop() + } + + // ── Hamburger ─────────────────────────────────────────────────────────────── + + #addHamburger() { + if (this.#hamburger) return + + const btn = document.createElement('button') + btn.innerHTML = HAMBURGER_ICON + btn.setAttribute('aria-label', 'Menu') + btn.setAttribute('data-sidenav-toggle', '') + Object.assign(btn.style, { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: '1px solid #313244', + borderRadius: '6px', + padding: '7px', + color: '#a6adc8', + cursor: 'pointer', + minWidth: '36px', + minHeight: '36px', + flexShrink: '0', + }) + btn.addEventListener('click', () => this.#toggle()) + this.#hamburger = btn + + // Insère après le h1 dans le header, ou en premier si pas de h1 + const header = document.querySelector('header') + if (!header) { document.body.prepend(btn); return } + const h1 = header.querySelector('h1') + h1 ? h1.after(btn) : header.prepend(btn) + } + + #removeHamburger() { + this.#hamburger?.remove() + this.#hamburger = null + } + + // ── Open / Close (mobile) ─────────────────────────────────────────────────── + + #toggle() { this.#isMobile && (this.style.transform === 'translateX(0px)' + ? this.#close() : this.#open()) } + + #open() { + this.style.transform = 'translateX(0)' + + const bd = document.createElement('div') + Object.assign(bd.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0)', + zIndex: '499', + transition: 'background 0.25s', + }) + bd.addEventListener('click', () => this.#close()) + document.body.appendChild(bd) + this.#backdrop = bd + requestAnimationFrame(() => { bd.style.background = 'rgba(0,0,0,0.45)' }) + } + + #close() { + this.style.transform = 'translateX(-100%)' + this.#removeBackdrop() + } + + #removeBackdrop() { + if (!this.#backdrop) return + const bd = this.#backdrop + this.#backdrop = null + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) } } diff --git a/web/components/side-panel/side-panel.js b/web/components/side-panel/side-panel.js index 9feaa4d..254160f 100644 --- a/web/components/side-panel/side-panel.js +++ b/web/components/side-panel/side-panel.js @@ -109,11 +109,13 @@ export class SidePanel extends HTMLElement { --offset: 0px; --width: ${width}; --dur: ${ANIM_DURATION}ms; + /* Cap width to viewport — never overflows on mobile */ + --actual-width: min(var(--width), 100vw); position: fixed; top: 0; - right: calc(-1 * var(--width)); - width: var(--width); + right: calc(-1 * var(--actual-width)); + width: var(--actual-width); height: 100vh; background: #1e1e2e; border-left: 1px solid #313244; @@ -121,8 +123,7 @@ export class SidePanel extends HTMLElement { display: flex; flex-direction: column; z-index: calc(1000 + var(--stack, 0)); - transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1), - transform var(--dur) cubic-bezier(0.4, 0, 0.2, 1); + transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #cdd6f4; } @@ -131,6 +132,16 @@ export class SidePanel extends HTMLElement { right: var(--offset); } + /* On mobile: always full-width, no stack offset */ + @media (max-width: 600px) { + :host { + --actual-width: 100vw; + } + :host([data-open]) { + right: 0; + } + } + .header { display: flex; align-items: center; diff --git a/web/index.html b/web/index.html index 19127ed..0961b64 100644 --- a/web/index.html +++ b/web/index.html @@ -59,6 +59,11 @@ gap: 20px; } + /* Mobile: side-nav gère sa propre géométrie via inline styles (JS) */ + @media (max-width: 768px) { + main { padding: 16px; } + } + #error { display: none; background: #313244; diff --git a/web/vpc.html b/web/vpc.html new file mode 100644 index 0000000..0961b64 --- /dev/null +++ b/web/vpc.html @@ -0,0 +1,140 @@ + + + + + + two — dashboard + + + + + + + + +
+

two

+ network orchestrator +
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ + + + +