feat: réécriture frontend Alpine.js + HTMX + Swup (branche frontend/alpine)

Remplace Vue 3 / Vite / TypeScript par une stack légère statique :
- Alpine.js v3 : réactivité inline, stores auth/ui/i18n, composants par page
- HTMX v2 : interactions serveur via attributs HTML
- Swup v4 : transitions de page (bundlé via esbuild, IIFE browser-loadable)
- xterm.js v5 : terminal PTY (bundlé via esbuild)

Structure : HTML statiques + js/app.js + js/terminal.js + css/ + locales/
Build : esbuild (bundle Swup + xterm seulement) → dist/ → Nginx
Dockerfile simplifié : node:22-alpine build → nginx:1.27-alpine serve

Pages : index, install, login, dashboard, proxmox, updates, terminal, settings, modules
URLs propres via nginx try_files $uri.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 16:19:24 +01:00
parent 7ba0ff143c
commit 2098c80ec1
48 changed files with 2446 additions and 5317 deletions

110
frontend/js/terminal.js Normal file
View file

@ -0,0 +1,110 @@
/**
* ProxmoxPanel Terminal page logic
* xterm.js + WebSocket PTY
* Chargé uniquement sur terminal.html
*/
document.addEventListener('DOMContentLoaded', () => {
// Les classes Terminal et FitAddon sont exposées par xterm.iife.js
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
console.error('xterm.js not loaded')
return
}
const term = new Terminal({
cursorBlink: true,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fontSize: 14,
theme: {
background: 'var(--bg-secondary, #1a1a2e)',
foreground: 'var(--text-primary, #e2e8f0)',
cursor: 'var(--accent-primary, #6366f1)',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
const container = document.getElementById('terminal-container')
if (!container) return
term.open(container)
fitAddon.fit()
// Connexion WebSocket PTY
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = localStorage.getItem('pxp_token')
const ws = new WebSocket(`${proto}://${location.host}/ws/terminal?token=${encodeURIComponent(token || '')}`)
ws.binaryType = 'arraybuffer'
const statusEl = document.getElementById('terminal-status')
function setStatus(text, cls) {
if (statusEl) {
statusEl.textContent = text
statusEl.className = 'terminal-status ' + (cls || '')
}
}
setStatus('Connexion…', 'connecting')
ws.onopen = () => {
setStatus('Connecté', 'connected')
// Envoyer la taille initiale du terminal
sendResize()
}
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
term.write(new Uint8Array(e.data))
} else {
term.write(e.data)
}
}
ws.onclose = () => {
setStatus('Déconnecté', 'disconnected')
term.writeln('\r\n\x1b[31m[Connexion terminée]\x1b[0m')
}
ws.onerror = () => {
setStatus('Erreur', 'error')
}
// Envoyer l'input utilisateur au serveur
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
// Envoyer la taille du terminal lors du redimensionnement
function sendResize() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'resize',
cols: term.cols,
rows: term.rows,
}))
}
}
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
sendResize()
})
resizeObserver.observe(container)
// Nettoyage quand Swup navigue hors de la page
document.addEventListener('swup:page:view', () => {
if (!document.getElementById('terminal-container')) {
ws.close()
resizeObserver.disconnect()
}
})
// Bouton "Effacer"
const clearBtn = document.getElementById('terminal-clear')
if (clearBtn) {
clearBtn.addEventListener('click', () => term.clear())
}
})