web: start: responsive work

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-06-01 19:33:27 +02:00
commit 158dd0451a
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
4 changed files with 331 additions and 48 deletions

View file

@ -1,7 +1,15 @@
// Side navigation component. // Side navigation component.
// Reads items from navigation.json (path relative to web root via data-base attribute, // Desktop : sidebar fixe à gauche dans le flux flex.
// defaults to ./navigation.json). // Mobile (≤768px) : overlay depuis la gauche, déclenché par un bouton hamburger
// Highlights the active entry by matching href against window.location.pathname. // inséré dans <header>. Géométrie gérée en inline style pour
// éviter les conflits de spécificité avec le shadow DOM.
const HAMBURGER_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<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>`
const ICONS = { const ICONS = {
dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`, dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
@ -10,90 +18,209 @@ const ICONS = {
vm: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`, vm: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
} }
const MQ = window.matchMedia('(max-width: 768px)')
const NAV_W = 220 // px
function resolveIcon(name) { function resolveIcon(name) {
return ICONS[name] ?? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>` return ICONS[name] ?? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`
} }
class SideNav extends HTMLElement { class SideNav extends HTMLElement {
#items = []
#hamburger = null
#backdrop = null
#mqHandler = null
#isMobile = false
async connectedCallback() { async connectedCallback() {
this.attachShadow({ mode: 'open' }) this.attachShadow({ mode: 'open' })
const navPath = this.dataset.src ?? './navigation.json' const navPath = this.dataset.src ?? './navigation.json'
const response = await fetch(navPath) const response = await fetch(navPath)
if (!response.ok) throw new Error(`side-nav: failed to load ${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) { disconnectedCallback() {
const currentPage = window.location.pathname.split('/').pop() || 'index.html' MQ.removeEventListener('change', this.#mqHandler)
this.#removeHamburger()
this.#removeBackdrop()
}
const links = items.map(item => { // ── Rendu des items ─────────────────────────────────────────────────────────
const isActive = item.href === currentPage
return ` #render() {
<a href="${item.href}" class="nav-item ${isActive ? 'active' : ''}"> const current = window.location.pathname.split('/').pop() || 'index.html'
const links = this.#items.map(item => {
const active = item.href === current
return `<a href="${item.href}" class="item ${active ? 'active' : ''}">
<span class="icon">${resolveIcon(item.icon)}</span> <span class="icon">${resolveIcon(item.icon)}</span>
<span class="label">${item.label}</span> <span class="label">${item.label}</span>
</a> </a>`
`
}).join('') }).join('')
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { :host {
/* Géométrie desktop définie ici ; mobile gérée par this.style en JS */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 220px; width: ${NAV_W}px;
min-width: 220px; min-width: ${NAV_W}px;
background: #1e1e2e; background: #1e1e2e;
border-right: 1px solid #313244; border-right: 1px solid #313244;
padding: 16px 12px; padding: 16px 12px;
gap: 4px; gap: 4px;
height: 100%; height: 100%;
overflow: hidden; /* empêche tout débordement */
box-sizing: border-box;
} }
.nav-item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 9px 12px; padding: 11px 12px;
border-radius: 6px; border-radius: 6px;
text-decoration: none; text-decoration: none;
color: #a6adc8; color: #a6adc8;
font-size: 14px; font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 44px;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
} }
.item:hover { background: #313244; color: #cdd6f4; }
.nav-item:hover { .item.active { background: #313244; color: #89b4fa; }
background: #313244; .item.active .icon { color: #89b4fa; }
color: #cdd6f4;
}
.nav-item.active {
background: #313244;
color: #89b4fa;
}
.nav-item.active .icon {
color: #89b4fa;
}
.icon { .icon {
display: flex; display: flex; align-items: center;
align-items: center; color: #6c7086; flex-shrink: 0;
color: #6c7086;
flex-shrink: 0;
}
.nav-item:hover .icon {
color: #cdd6f4;
} }
.item:hover .icon { color: #cdd6f4; }
</style> </style>
${links} ${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 })
} }
} }

View file

@ -109,11 +109,13 @@ export class SidePanel extends HTMLElement {
--offset: 0px; --offset: 0px;
--width: ${width}; --width: ${width};
--dur: ${ANIM_DURATION}ms; --dur: ${ANIM_DURATION}ms;
/* Cap width to viewport — never overflows on mobile */
--actual-width: min(var(--width), 100vw);
position: fixed; position: fixed;
top: 0; top: 0;
right: calc(-1 * var(--width)); right: calc(-1 * var(--actual-width));
width: var(--width); width: var(--actual-width);
height: 100vh; height: 100vh;
background: #1e1e2e; background: #1e1e2e;
border-left: 1px solid #313244; border-left: 1px solid #313244;
@ -121,8 +123,7 @@ export class SidePanel extends HTMLElement {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: calc(1000 + var(--stack, 0)); z-index: calc(1000 + var(--stack, 0));
transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1), transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1);
transform var(--dur) cubic-bezier(0.4, 0, 0.2, 1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #cdd6f4; color: #cdd6f4;
} }
@ -131,6 +132,16 @@ export class SidePanel extends HTMLElement {
right: var(--offset); 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 { .header {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -59,6 +59,11 @@
gap: 20px; gap: 20px;
} }
/* Mobile: side-nav gère sa propre géométrie via inline styles (JS) */
@media (max-width: 768px) {
main { padding: 16px; }
}
#error { #error {
display: none; display: none;
background: #313244; background: #313244;

140
web/vpc.html Normal file
View file

@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>two — dashboard</title>
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #181825;
color: #cdd6f4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid #313244;
background: #1e1e2e;
flex-shrink: 0;
}
header h1 {
font-size: 17px;
font-weight: 600;
color: #89b4fa;
letter-spacing: 0.02em;
}
header span {
font-size: 12px;
color: #6c7086;
}
header .spacer {
flex: 1;
}
.layout {
display: flex;
flex: 1;
overflow: hidden;
}
main {
flex: 1;
padding: 28px;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
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;
border: 1px solid #f38ba8;
border-radius: 8px;
padding: 14px 18px;
color: #f38ba8;
font-family: monospace;
font-size: 13px;
width: 100%;
}
</style>
</head>
<body>
<login-gate></login-gate>
<api-client></api-client>
<header>
<h1>two</h1>
<span>network orchestrator</span>
<div class="spacer"></div>
<logout-button></logout-button>
</header>
<div class="layout">
<side-nav></side-nav>
<main>
<div id="error"></div>
<date-display></date-display>
<!-- Dev test: open stacked panels -->
<div style="display:flex;gap:8px;margin-top:8px;">
<button onclick="openTestPanel('Panel A', '400px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
Open panel A
</button>
<button onclick="openTestPanel('Panel B', '520px')"
style="background:#313244;border:1px solid #45475a;border-radius:6px;
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
Open panel B
</button>
</div>
</main>
</div>
<script type="module">
import './core/registry.js'
import { SidePanel } from './components/side-panel/side-panel.js'
window.openTestPanel = (title, width) => {
SidePanel.open({
title,
width,
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
Contenu du panneau <strong style="color:#cdd6f4">${title}</strong>.<br>
Largeur : ${width}.<br><br>
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
Ferme avec ✕, Échap, ou en cliquant le fond.
</p>`,
})
}
window.addEventListener('unhandledrejection', e => {
const el = document.getElementById('error')
el.style.display = 'block'
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
})
</script>
</body>
</html>