feat(frontend): Service Worker WS, mode édition dashboard, sidebar click-to-toggle

- WebSocket via Service Worker (ws.sw.js) : connexions persistantes entre navigations
  Swup, reconnexion exponentielle, protocole WS_SUBSCRIBE/WS_UNSUBSCRIBE/WS_SEND
- WsProxy dans app.js : abstraction SW + fallback WebSocket direct
- proxmoxPage migré vers WsProxy (identique au dashboardPage)
- Dashboard : mode édition toggle — DnD, resize (x1/x2), masquer/afficher widget
  uniquement actifs en mode édition ; preview drag (is-dragging/drag-over)
- Sidebar : suppression bouton hamburger, clic sur sidebar-header pour replier
- pages.css : targets-grid 350px, styles edit mode, widget-size-2, drag preview
- neu.css : sidebar-header cursor pointer, suppression .sidebar-toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 19:09:58 +01:00
parent b6d6355c6c
commit cbfb20505d
11 changed files with 359 additions and 128 deletions

View file

@ -431,6 +431,8 @@ input, select, textarea { font-family: inherit; font-size: inherit; }
border-bottom: 1px solid var(--neu-border);
min-height: var(--navbar-height);
flex-shrink: 0;
cursor: pointer;
user-select: none;
}
.sidebar-logo {
@ -447,10 +449,6 @@ input, select, textarea { font-family: inherit; font-size: inherit; }
color: var(--neu-text);
}
.sidebar-toggle {
margin-left: auto;
flex-shrink: 0;
}
.sidebar-nav {
flex: 1;

View file

@ -196,7 +196,7 @@
.total-badge { font-size: .875rem; font-weight: 600; color: var(--neu-primary); }
.targets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1rem;
}
.target-card { padding: 1rem; display: flex; flex-direction: column; gap: .75rem; }
@ -548,6 +548,58 @@
gap: .375rem;
}
/* Widget edit mode */
.widget-size-2 { grid-column: span 2; }
@media (max-width: 720px) { .widget-size-2 { grid-column: span 1; } }
.widget.is-dragging { opacity: 0.35; transform: scale(.97); }
.widget.drag-over { outline: 2px dashed var(--neu-primary); transform: scale(1.02); }
.widget-edit-bar {
display: flex;
align-items: center;
gap: .375rem;
padding: .25rem 0 .75rem;
border-bottom: 1px solid var(--neu-border);
margin-bottom: .75rem;
}
.widget-edit-bar-title { flex: 1; font-size: .75rem; font-weight: 600; color: var(--neu-text-muted); text-transform: uppercase; }
.edit-mode-banner {
display: flex;
align-items: center;
gap: .75rem;
padding: .625rem 1rem;
border-radius: var(--neu-radius-sm);
background: rgba(108, 142, 244, 0.1);
border: 1px solid var(--neu-primary);
margin-bottom: 1.25rem;
font-size: .875rem;
color: var(--neu-primary);
}
.edit-mode-banner i { font-size: 1.1rem; }
.widget-add-grid {
display: flex;
flex-wrap: wrap;
gap: .75rem;
margin-top: 1.5rem;
}
.widget-add-btn {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem 1rem;
border-radius: var(--neu-radius-sm);
border: 1px dashed var(--neu-border);
background: transparent;
color: var(--neu-text-muted);
cursor: pointer;
font-size: .875rem;
transition: all .15s;
}
.widget-add-btn:hover { border-color: var(--neu-primary); color: var(--neu-primary); }
/* ── Profil / préférences ────────────────────────────────────────────────────── */
.settings-section {
margin-bottom: 1.25rem;

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">
@ -62,47 +59,68 @@
<div class="page-header">
<h2 class="page-title" x-text="t('dashboard.welcome').replace('{name}', $store.auth.user?.username || '')"></h2>
<button class="neu-btn neu-btn--sm" @click="configOpen = !configOpen" :title="t('dashboard.addWidget')">
<i class="lnid-layout-1"></i>
<span x-show="!configOpen" x-text="t('dashboard.addWidget')"></span>
<span x-show="configOpen"></span>
<button class="neu-btn neu-btn--sm" :class="{ 'neu-btn--primary': editMode }" @click="toggleEdit()">
<i class="lnid-pen-1"></i>
<span x-text="editMode ? '✓ Terminer' : t('dashboard.addWidget')"></span>
</button>
</div>
<!-- Config panel widgets -->
<div class="widget-config neu-card" x-show="configOpen" x-transition>
<h4 class="widget-config-title">Configurer les widgets</h4>
<template x-for="(w, idx) in widgets" :key="w.id">
<div class="widget-config-row"
draggable="true"
@dragstart="onDragStart(idx)"
@dragover.prevent
@drop.prevent="onDrop(idx)">
<i class="lnid-menu-hamburger-1 drag-handle"></i>
<span x-text="w.label"></span>
<label class="widget-toggle-label">
<input type="checkbox" x-model="w.visible" @change="saveWidgets()" />
<span x-text="w.visible ? 'Visible' : 'Masqué'"></span>
</label>
<!-- Mode édition — bannière + widgets masqués -->
<div x-show="editMode" x-transition>
<div class="edit-mode-banner">
<i class="lnid-pen-1"></i>
<span>Mode édition — glissez les widgets pour les réordonner, redimensionnez ou masquez-les.</span>
</div>
<!-- Widgets masqués (à ajouter) -->
<div x-show="hiddenWidgets.length > 0">
<div class="widget-add-grid">
<template x-for="w in hiddenWidgets" :key="w.id">
<button class="widget-add-btn" @click="showWidget(w.id)">
<i class="lnid-add-circle"></i>
<span x-text="w.label"></span>
</button>
</template>
</div>
</div>
</div>
<!-- WS status -->
<div class="ws-status" :class="wsStatus" x-show="wsStatus !== 'connected'">
<div class="ws-status" :class="wsStatus" x-show="wsStatus !== 'connected'" x-transition>
<span x-show="wsStatus === 'connecting'">⌛ Connexion…</span>
<span x-show="wsStatus === 'disconnected'">⚠ Déconnecté (reconnexion…)</span>
<span x-show="wsStatus === 'error'">✗ Erreur WebSocket</span>
</div>
<!-- Widgets -->
<!-- Widgets grid -->
<div class="widgets-grid">
<template x-for="(w, idx) in widgets.filter(w => w.visible)" :key="w.id">
<template x-for="(w, idx) in visibleWidgets" :key="w.id">
<div class="neu-card widget"
draggable="true"
@dragstart="onDragStart(widgets.indexOf(w))"
:class="{
'widget-size-2': w.size === 2,
'is-dragging': editMode && dragSrcIdx === widgets.indexOf(w),
'drag-over': editMode && dragOverIdx === widgets.indexOf(w),
}"
:draggable="editMode"
@dragstart="editMode && onDragStart(widgets.indexOf(w))"
@dragenter.prevent="editMode && onDragEnter(widgets.indexOf(w))"
@dragleave="editMode && onDragLeave(widgets.indexOf(w))"
@dragover.prevent
@drop.prevent="onDrop(widgets.indexOf(w))">
@dragend="onDragEnd()"
@drop.prevent="editMode && onDrop(widgets.indexOf(w))">
<!-- Barre d'édition (visible en mode édition) -->
<div class="widget-edit-bar" x-show="editMode">
<i class="lnid-menu-hamburger-1 drag-handle"></i>
<span class="widget-edit-bar-title" x-text="w.label"></span>
<button class="neu-btn neu-btn--sm" @click.stop="toggleSize(w)"
:title="w.size === 2 ? 'Réduire' : 'Agrandir'">
<i :class="w.size === 2 ? 'lnid-compress-arrows' : 'lnid-expand-arrows'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click.stop="hideWidget(w)" title="Masquer">
<i class="lnid-close"></i>
</button>
</div>
<!-- Widget: status -->
<template x-if="w.id === 'status'">
@ -171,8 +189,8 @@
</div>
</template>
<p class="empty-state" x-show="widgets.filter(w => w.visible).length === 0">
Aucun widget actif. Cliquez sur "Configurer" pour en ajouter.
<p class="empty-state" x-show="visibleWidgets.length === 0 && !editMode">
Aucun widget actif — activez le mode édition pour en ajouter.
</p>
</div>
</main>

View file

@ -5,6 +5,80 @@
* L'événement 'alpine:init' est déclenché par Alpine avant qu'il parcourt le DOM.
*/
// ── WsProxy — WebSocket via Service Worker avec fallback direct ─────────────
const WsProxy = {
_subs: new Map(), // channel → [{onMessage, onStatus}]
_direct: new Map(), // channel → WebSocket (fallback sans SW)
init() {
if (!('serviceWorker' in navigator)) return
navigator.serviceWorker.register('/js/ws.sw.js')
.catch(e => console.warn('[WsProxy] SW registration failed:', e))
navigator.serviceWorker.addEventListener('message', event => {
const { channel, type, data, status } = event.data || {}
if (!channel) return
const subs = this._subs.get(channel) || []
if (type === 'WS_MESSAGE') subs.forEach(s => s.onMessage?.(data))
if (type === 'WS_STATUS') subs.forEach(s => s.onStatus?.(status))
})
},
async subscribe(channel, url, onMessage, onStatus) {
if (!this._subs.has(channel)) this._subs.set(channel, [])
const sub = { onMessage, onStatus }
this._subs.get(channel).push(sub)
if ('serviceWorker' in navigator) {
try {
await navigator.serviceWorker.ready
const sw = navigator.serviceWorker.controller
if (sw) {
sw.postMessage({ type: 'WS_SUBSCRIBE', channel, url })
return () => this._unsub(channel, sub, true)
}
} catch(e) { /* fallback */ }
}
this._connectDirect(channel, url)
return () => this._unsub(channel, sub, false)
},
_unsub(channel, sub, usedSW) {
const subs = this._subs.get(channel) || []
const idx = subs.indexOf(sub)
if (idx > -1) subs.splice(idx, 1)
if (usedSW) {
navigator.serviceWorker?.controller?.postMessage({ type: 'WS_UNSUBSCRIBE', channel })
} else if (subs.length === 0) {
this._direct.get(channel)?.close()
this._direct.delete(channel)
}
},
_connectDirect(channel, url) {
const ex = this._direct.get(channel)
if (ex && ex.readyState < 2) return
const ws = new WebSocket(url)
this._direct.set(channel, ws)
const fire = (cb, arg) => (this._subs.get(channel) || []).forEach(s => s[cb]?.(arg))
ws.onopen = () => fire('onStatus', 'connected')
ws.onmessage = (e) => fire('onMessage', e.data)
ws.onerror = () => fire('onStatus', 'error')
ws.onclose = () => {
fire('onStatus', 'disconnected')
setTimeout(() => {
if ((this._subs.get(channel) || []).length > 0) this._connectDirect(channel, url)
}, 3000)
}
},
send(channel, data) {
const sw = navigator.serviceWorker?.controller
if (sw) sw.postMessage({ type: 'WS_SEND', channel, payload: data })
else this._direct.get(channel)?.send(data)
},
}
// ── Utilitaires ────────────────────────────────────────────────────────────
function apiFetch(path, opts = {}) {
@ -314,81 +388,107 @@ document.addEventListener('alpine:init', () => {
// ── Composant: dashboardPage ────────────────────────────────────────────
Alpine.data('dashboardPage', () => ({
resources: [],
ws: null,
_unsubWS: null,
wsStatus: 'connecting',
configOpen: false,
editMode: false,
dragSrcIdx: null,
dragOverIdx: null,
widgets: (function() {
const defaults = [
{ id: 'status', visible: true, size: 1, label: 'Statut LXC' },
{ id: 'lxc-list', visible: true, size: 1, label: 'Liste LXC' },
{ id: 'links', visible: false, size: 1, label: 'Raccourcis' },
]
try {
return JSON.parse(localStorage.getItem('pxp_widgets') || 'null') || [
{ id: 'status', visible: true, label: 'Statut LXC' },
{ id: 'lxc-list', visible: true, label: 'Liste LXC' },
{ id: 'links', visible: false, label: 'Raccourcis' },
]
} catch(e) {
return [
{ id: 'status', visible: true, label: 'Statut LXC' },
{ id: 'lxc-list', visible: true, label: 'Liste LXC' },
{ id: 'links', visible: false, label: 'Raccourcis' },
]
}
const saved = JSON.parse(localStorage.getItem('pxp_widgets') || 'null')
if (!saved) return defaults
// Fusionner pour ajouter d'éventuels nouveaux widgets par défaut
const ids = saved.map(w => w.id)
return [...saved, ...defaults.filter(d => !ids.includes(d.id))]
} catch(e) { return defaults }
})(),
saveWidgets() { localStorage.setItem('pxp_widgets', JSON.stringify(this.widgets)) },
onDragStart(idx) { this.dragSrcIdx = idx },
onDrop(idx) {
if (this.dragSrcIdx === null || this.dragSrcIdx === idx) return
const moved = this.widgets.splice(this.dragSrcIdx, 1)[0]
this.widgets.splice(idx, 0, moved)
this.dragSrcIdx = null
// ── Edition
toggleEdit() { this.editMode = !this.editMode },
toggleSize(w) {
w.size = (w.size || 1) === 1 ? 2 : 1
this.saveWidgets()
},
showWidget(id) {
const w = this.widgets.find(w => w.id === id)
if (w) { w.visible = true; this.saveWidgets() }
},
hideWidget(w) {
w.visible = false
this.saveWidgets()
},
// ── DnD avec preview
onDragStart(idx) {
this.dragSrcIdx = idx
},
onDragEnter(idx) {
if (this.dragSrcIdx !== null && this.dragSrcIdx !== idx) this.dragOverIdx = idx
},
onDragLeave(idx) {
if (this.dragOverIdx === idx) this.dragOverIdx = null
},
onDragEnd() {
this.dragSrcIdx = null
this.dragOverIdx = null
},
onDrop(idx) {
if (this.dragSrcIdx === null || this.dragSrcIdx === idx) {
this.onDragEnd(); return
}
const moved = this.widgets.splice(this.dragSrcIdx, 1)[0]
this.widgets.splice(idx, 0, moved)
this.onDragEnd()
this.saveWidgets()
},
// ── Data
async init() {
await this.fetchResources()
this.connectWS()
await this.connectWS()
},
destroy() {
if (this.ws) this.ws.close()
if (this._unsubWS) { this._unsubWS(); this._unsubWS = null }
},
async fetchResources() {
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
this.resources = await res.json() || []
this.wsStatus = 'connected'
}
if (res.ok) { this.resources = await res.json() || []; this.wsStatus = 'connected' }
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
async connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'resources_update') {
this.resources = msg.payload || []
this.wsStatus = 'connected'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
const url = `${proto}://${location.host}/ws/proxmox?token=${token}`
this._unsubWS = await WsProxy.subscribe(
'proxmox', url,
(data) => {
const msg = JSON.parse(data)
if (msg.type === 'resources_update') { this.resources = msg.payload || []; this.wsStatus = 'connected' }
},
(status) => { this.wsStatus = status }
)
},
get visibleWidgets() { return this.widgets.filter(w => w.visible) },
get hiddenWidgets() { return this.widgets.filter(w => !w.visible) },
get lxc() { return this.resources.filter(r => r.type === 'lxc') },
get running() { return this.lxc.filter(r => r.status === 'running') },
get stopped() { return this.lxc.filter(r => r.status !== 'running') },
// compat
get runningCount() { return this.running.length },
get stoppedCount() { return this.stopped.length },
get lxcList() { return this.lxc },
get vmList() { return this.resources.filter(r => r.type === 'qemu') },
@ -431,43 +531,38 @@ document.addEventListener('alpine:init', () => {
// ── Composant: proxmoxPage ──────────────────────────────────────────────
Alpine.data('proxmoxPage', () => ({
resources: [],
ws: null,
_unsubWS: null,
wsStatus: 'connecting',
actionLoading: {},
async init() {
await this.fetchResources()
this.connectWS()
await this.connectWS()
},
destroy() { if (this.ws) this.ws.close() },
destroy() { if (this._unsubWS) { this._unsubWS(); this._unsubWS = null } },
async fetchResources() {
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
this.resources = await res.json() || []
this.wsStatus = 'ok'
this.wsStatus = 'connected'
}
} catch (e) { /* WS prendra le relais */ }
},
connectWS() {
async connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
// Le backend publie type="resources_update", payload=[...]
if (msg.type === 'resources_update') {
this.resources = msg.payload || []
this.wsStatus = 'ok'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
const url = `${proto}://${location.host}/ws/proxmox?token=${token}`
this._unsubWS = await WsProxy.subscribe(
'proxmox', url,
(data) => {
const msg = JSON.parse(data)
if (msg.type === 'resources_update') { this.resources = msg.payload || []; this.wsStatus = 'connected' }
},
(status) => { this.wsStatus = status }
)
},
async action(vmid, type, action) {

86
frontend/js/ws.sw.js Normal file
View file

@ -0,0 +1,86 @@
/**
* ProxmoxPanel WebSocket Service Worker
* Maintient les connexions WS en vie entre les navigations Swup.
* Les pages s'abonnent/désabonnent via postMessage.
*/
'use strict'
// channel → { ws: WebSocket|null, url: string, clientIds: Set<string>, retryDelay: number }
const connections = new Map()
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', e =>
e.waitUntil(self.clients.claim())
)
self.addEventListener('message', event => {
const { type, channel, url, payload } = event.data || {}
if (!channel) return
const clientId = event.source?.id
if (!clientId) return
switch (type) {
case 'WS_SUBSCRIBE': {
let conn = connections.get(channel)
if (!conn) {
conn = { ws: null, url: null, clientIds: new Set(), retryDelay: 2000 }
connections.set(channel, conn)
}
conn.clientIds.add(clientId)
if (url) conn.url = url
ensureWS(channel)
break
}
case 'WS_UNSUBSCRIBE': {
const conn = connections.get(channel)
if (conn) conn.clientIds.delete(clientId)
break
}
case 'WS_SEND': {
const conn = connections.get(channel)
if (conn?.ws?.readyState === 1) conn.ws.send(payload)
break
}
}
})
function ensureWS(channel) {
const conn = connections.get(channel)
if (!conn?.url) return
if (conn.ws && conn.ws.readyState < 2) return // CONNECTING (0) ou OPEN (1)
const ws = new WebSocket(conn.url)
conn.ws = ws
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connecting' })
ws.onopen = () => {
conn.retryDelay = 2000
notify(channel, 'WS_STATUS', { status: 'connected' })
}
ws.onmessage = e => notify(channel, 'WS_MESSAGE', { data: e.data })
ws.onerror = () => notify(channel, 'WS_STATUS', { status: 'error' })
ws.onclose = () => {
notify(channel, 'WS_STATUS', { status: 'disconnected' })
// Backoff exponentiel plafonné à 30s
const delay = conn.retryDelay
conn.retryDelay = Math.min(conn.retryDelay * 1.5, 30000)
setTimeout(() => ensureWS(channel), delay)
}
}
async function notify(channel, type, extra) {
const conn = connections.get(channel)
if (!conn || conn.clientIds.size === 0) return
const allClients = await self.clients.matchAll({ type: 'window' })
for (const client of allClients) {
if (conn.clientIds.has(client.id)) {
client.postMessage({ channel, type, ...extra })
}
}
}

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">

View file

@ -21,12 +21,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">

View file

@ -18,12 +18,9 @@
<body x-data x-init="$store.ui.init()">
<aside class="sidebar" x-data="sidebar()" :class="{ collapsed: collapsed }" x-cloak>
<div class="sidebar-header">
<div class="sidebar-header" @click="toggle()">
<i class="sidebar-logo lnid-layout-1"></i>
<span class="sidebar-title" x-show="!collapsed">ProxmoxPanel</span>
<button class="sidebar-toggle neu-btn neu-btn--sm" @click="toggle()">
<i class="lnid-menu-hamburger-1"></i>
</button>
</div>
<nav class="sidebar-nav">
<template x-for="item in navItems" :key="item.id">