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:
parent
7ba0ff143c
commit
2098c80ec1
48 changed files with 2446 additions and 5317 deletions
710
frontend/js/app.js
Normal file
710
frontend/js/app.js
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
/**
|
||||
* ProxmoxPanel — Alpine.js stores + composants + Swup init + HTMX config
|
||||
*
|
||||
* Chargé AVANT alpine.min.js (qui est defer).
|
||||
* L'événement 'alpine:init' est déclenché par Alpine avant qu'il parcourt le DOM.
|
||||
*/
|
||||
|
||||
// ── Utilitaires ────────────────────────────────────────────────────────────
|
||||
|
||||
function apiFetch(path, opts = {}) {
|
||||
const token = localStorage.getItem('pxp_token')
|
||||
return fetch(path, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
...(opts.headers || {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Alpine:init ────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
|
||||
// ── Store auth ─────────────────────────────────────────────────────────
|
||||
Alpine.store('auth', {
|
||||
token: null,
|
||||
user: null,
|
||||
get isAuthenticated() { return !!this.token && !!this.user },
|
||||
|
||||
async init() {
|
||||
this.token = localStorage.getItem('pxp_token')
|
||||
if (this.token) {
|
||||
try {
|
||||
await this.fetchMe()
|
||||
} catch {
|
||||
await this.tryRefresh()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMe() {
|
||||
const res = await apiFetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
this.user = await res.json()
|
||||
} else if (res.status === 401) {
|
||||
await this.tryRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
async tryRefresh() {
|
||||
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.token = data.token
|
||||
localStorage.setItem('pxp_token', data.token)
|
||||
await this.fetchMe()
|
||||
} else {
|
||||
this.clear()
|
||||
}
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Identifiants invalides')
|
||||
}
|
||||
const data = await res.json()
|
||||
this.token = data.token
|
||||
this.user = data.user
|
||||
localStorage.setItem('pxp_token', data.token)
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
this.clear()
|
||||
window.location.href = '/login.html'
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.token = null
|
||||
this.user = null
|
||||
localStorage.removeItem('pxp_token')
|
||||
},
|
||||
})
|
||||
|
||||
// ── Store UI ────────────────────────────────────────────────────────────
|
||||
Alpine.store('ui', {
|
||||
theme: localStorage.getItem('pxp_theme') || 'dark',
|
||||
sidebarCollapsed: localStorage.getItem('pxp_sidebar') === 'true',
|
||||
currentPage: '',
|
||||
|
||||
init() {
|
||||
this.applyTheme()
|
||||
// Detect current page from URL
|
||||
const path = window.location.pathname
|
||||
this.currentPage = path.replace(/^\/|\.html$/g, '') || 'index'
|
||||
},
|
||||
|
||||
applyTheme() {
|
||||
document.documentElement.setAttribute('data-theme', this.theme)
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('pxp_theme', this.theme)
|
||||
this.applyTheme()
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed
|
||||
localStorage.setItem('pxp_sidebar', this.sidebarCollapsed)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Store i18n ──────────────────────────────────────────────────────────
|
||||
Alpine.store('i18n', {
|
||||
lang: localStorage.getItem('pxp_lang') || 'fr',
|
||||
msgs: {},
|
||||
loaded: false,
|
||||
|
||||
async init() {
|
||||
await this.load(this.lang)
|
||||
},
|
||||
|
||||
async load(lang) {
|
||||
try {
|
||||
const res = await fetch(`/locales/${lang}.json`)
|
||||
if (res.ok) {
|
||||
this.msgs = await res.json()
|
||||
this.lang = lang
|
||||
localStorage.setItem('pxp_lang', lang)
|
||||
this.loaded = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('i18n load error', e)
|
||||
}
|
||||
},
|
||||
|
||||
t(key, vars = {}) {
|
||||
const parts = key.split('.')
|
||||
let val = this.msgs
|
||||
for (const p of parts) {
|
||||
val = val?.[p]
|
||||
if (val === undefined) return key
|
||||
}
|
||||
if (typeof val !== 'string') return key
|
||||
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
|
||||
},
|
||||
|
||||
async setLang(lang) {
|
||||
await this.load(lang)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Composant: sidebar ──────────────────────────────────────────────────
|
||||
Alpine.data('sidebar', () => ({
|
||||
get collapsed() { return Alpine.store('ui').sidebarCollapsed },
|
||||
get currentPage() { return Alpine.store('ui').currentPage },
|
||||
|
||||
navItems: [
|
||||
{ id: 'dashboard', icon: '⊞', labelKey: 'nav.dashboard', href: '/dashboard.html' },
|
||||
{ id: 'proxmox', icon: '⬡', labelKey: 'nav.proxmox', href: '/proxmox.html' },
|
||||
{ id: 'updates', icon: '↑', labelKey: 'nav.updates', href: '/updates.html' },
|
||||
{ id: 'terminal', icon: '❯', labelKey: 'nav.terminal', href: '/terminal.html' },
|
||||
{ id: 'settings', icon: '⚙', labelKey: 'nav.settings', href: '/settings.html' },
|
||||
{ id: 'modules', icon: '⬡', labelKey: 'nav.modules', href: '/modules.html' },
|
||||
],
|
||||
|
||||
isActive(id) {
|
||||
return this.currentPage === id
|
||||
},
|
||||
|
||||
navigate(href) {
|
||||
if (window.swup) {
|
||||
window.swup.navigate(href)
|
||||
} else {
|
||||
window.location.href = href
|
||||
}
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
toggle() { Alpine.store('ui').toggleSidebar() },
|
||||
}))
|
||||
|
||||
// ── Composant: navbar ───────────────────────────────────────────────────
|
||||
Alpine.data('navbar', () => ({
|
||||
get theme() { return Alpine.store('ui').theme },
|
||||
get user() { return Alpine.store('auth').user },
|
||||
get lang() { return Alpine.store('i18n').lang },
|
||||
|
||||
toggleTheme() { Alpine.store('ui').toggleTheme() },
|
||||
logout() { Alpine.store('auth').logout() },
|
||||
async setLang(lang) { await Alpine.store('i18n').setLang(lang) },
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: loginPage ────────────────────────────────────────────────
|
||||
Alpine.data('loginPage', () => ({
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
loading: false,
|
||||
|
||||
async submit() {
|
||||
this.error = ''
|
||||
this.loading = true
|
||||
try {
|
||||
await Alpine.store('auth').login(this.username, this.password)
|
||||
window.location.href = '/dashboard.html'
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: installPage ──────────────────────────────────────────────
|
||||
Alpine.data('installPage', () => ({
|
||||
step: 1,
|
||||
totalSteps: 4,
|
||||
error: '',
|
||||
loading: false,
|
||||
sshTesting: false,
|
||||
sshStatus: '',
|
||||
|
||||
form: {
|
||||
instance_name: 'ProxmoxPanel',
|
||||
public_url: window.location.origin,
|
||||
default_lang: 'fr',
|
||||
ssh_host: '',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
proxmox_url: '',
|
||||
proxmox_token_id: '',
|
||||
proxmox_token_secret: '',
|
||||
},
|
||||
|
||||
async testSSH() {
|
||||
this.sshTesting = true
|
||||
this.sshStatus = ''
|
||||
try {
|
||||
const res = await fetch('/api/install/test-ssh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
host: this.form.ssh_host,
|
||||
username: this.form.ssh_username,
|
||||
password: this.form.ssh_password,
|
||||
}),
|
||||
})
|
||||
this.sshStatus = res.ok ? 'ok' : 'error'
|
||||
} catch {
|
||||
this.sshStatus = 'error'
|
||||
} finally {
|
||||
this.sshTesting = false
|
||||
}
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
if (this.step < this.totalSteps) this.step++
|
||||
},
|
||||
prevStep() {
|
||||
if (this.step > 1) this.step--
|
||||
},
|
||||
|
||||
async finish() {
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const res = await fetch('/api/install/configure', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.form),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
throw new Error(d.error || 'Erreur installation')
|
||||
}
|
||||
window.location.href = '/login.html'
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
t(key, vars) { return Alpine.store('i18n').t(key, vars) },
|
||||
}))
|
||||
|
||||
// ── Composant: dashboardPage ────────────────────────────────────────────
|
||||
Alpine.data('dashboardPage', () => ({
|
||||
resources: [],
|
||||
ws: null,
|
||||
wsStatus: 'connecting',
|
||||
|
||||
init() {
|
||||
this.connectWS()
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.ws) this.ws.close()
|
||||
},
|
||||
|
||||
connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox`)
|
||||
this.ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'proxmox_resources') {
|
||||
this.resources = msg.data || []
|
||||
this.wsStatus = 'ok'
|
||||
}
|
||||
}
|
||||
this.ws.onclose = () => {
|
||||
this.wsStatus = 'disconnected'
|
||||
setTimeout(() => this.connectWS(), 5000)
|
||||
}
|
||||
this.ws.onerror = () => { this.wsStatus = 'error' }
|
||||
},
|
||||
|
||||
get runningCount() { return this.resources.filter(r => r.status === 'running').length },
|
||||
get stoppedCount() { return this.resources.filter(r => r.status !== 'running').length },
|
||||
get lxcList() { return this.resources.filter(r => r.type === 'lxc') },
|
||||
get vmList() { return this.resources.filter(r => r.type === 'qemu') },
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: proxmoxPage ──────────────────────────────────────────────
|
||||
Alpine.data('proxmoxPage', () => ({
|
||||
resources: [],
|
||||
ws: null,
|
||||
wsStatus: 'connecting',
|
||||
actionLoading: {},
|
||||
|
||||
init() { this.connectWS() },
|
||||
destroy() { if (this.ws) this.ws.close() },
|
||||
|
||||
connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox`)
|
||||
this.ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'proxmox_resources') {
|
||||
this.resources = msg.data || []
|
||||
this.wsStatus = 'ok'
|
||||
}
|
||||
}
|
||||
this.ws.onclose = () => {
|
||||
this.wsStatus = 'disconnected'
|
||||
setTimeout(() => this.connectWS(), 5000)
|
||||
}
|
||||
this.ws.onerror = () => { this.wsStatus = 'error' }
|
||||
},
|
||||
|
||||
async action(vmid, type, action) {
|
||||
const key = `${vmid}-${action}`
|
||||
this.actionLoading[key] = true
|
||||
try {
|
||||
await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' })
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.actionLoading[key] = false
|
||||
}
|
||||
},
|
||||
|
||||
cpuColor(pct) {
|
||||
if (pct > 80) return 'var(--color-error)'
|
||||
if (pct > 50) return 'var(--color-warning)'
|
||||
return 'var(--color-success)'
|
||||
},
|
||||
|
||||
formatMem(bytes) {
|
||||
if (!bytes) return '0 MB'
|
||||
return Math.round(bytes / 1024 / 1024) + ' MB'
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: updatesPage ──────────────────────────────────────────────
|
||||
Alpine.data('updatesPage', () => ({
|
||||
targets: [],
|
||||
loading: true,
|
||||
ws: null,
|
||||
currentJob: null,
|
||||
output: '',
|
||||
jobStatus: '',
|
||||
|
||||
async init() {
|
||||
await this.loadTargets()
|
||||
await this.checkAll()
|
||||
},
|
||||
|
||||
destroy() { if (this.ws) this.ws.close() },
|
||||
|
||||
async loadTargets() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await apiFetch('/api/proxmox/resources')
|
||||
if (res.ok) {
|
||||
const resources = await res.json() || []
|
||||
this.targets = [
|
||||
{ id: 'host', name: 'Proxmox Host', status: 'running', packages: null, checking: false, updating: false },
|
||||
...resources
|
||||
.filter(r => r.type === 'lxc')
|
||||
.map(r => ({
|
||||
id: `lxc:${r.vmid}`,
|
||||
name: r.name || `LXC ${r.vmid}`,
|
||||
status: r.status,
|
||||
vmid: r.vmid,
|
||||
packages: null,
|
||||
checking: false,
|
||||
updating: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('loadTargets', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async checkTarget(target) {
|
||||
target.checking = true
|
||||
target.packages = null
|
||||
try {
|
||||
const res = await apiFetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`)
|
||||
if (res.ok) {
|
||||
target.packages = await res.json()
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('checkTarget', e)
|
||||
} finally {
|
||||
target.checking = false
|
||||
}
|
||||
},
|
||||
|
||||
async checkAll() {
|
||||
for (const t of this.targets) {
|
||||
await this.checkTarget(t)
|
||||
}
|
||||
},
|
||||
|
||||
async updateTarget(target) {
|
||||
target.updating = true
|
||||
this.output = ''
|
||||
this.jobStatus = 'running'
|
||||
try {
|
||||
const res = await apiFetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target: target.id }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Erreur démarrage mise à jour')
|
||||
const data = await res.json()
|
||||
this.currentJob = data.job_id
|
||||
await this.watchJob(data.job_id)
|
||||
target.packages = []
|
||||
} catch(e) {
|
||||
this.output += '\n[ERREUR] ' + e.message
|
||||
this.jobStatus = 'error'
|
||||
} finally {
|
||||
target.updating = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateAll() {
|
||||
this.output = ''
|
||||
this.jobStatus = 'running'
|
||||
try {
|
||||
const res = await apiFetch('/api/updates/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target: 'all' }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Erreur démarrage')
|
||||
const data = await res.json()
|
||||
this.currentJob = data.job_id
|
||||
await this.watchJob(data.job_id)
|
||||
for (const t of this.targets) t.packages = []
|
||||
} catch(e) {
|
||||
this.output += '\n[ERREUR] ' + e.message
|
||||
this.jobStatus = 'error'
|
||||
}
|
||||
},
|
||||
|
||||
watchJob(jobId) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.ws) this.ws.close()
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const token = localStorage.getItem('pxp_token')
|
||||
this.ws = new WebSocket(`${proto}://${location.host}/ws/updates/${jobId}?token=${token}`)
|
||||
this.ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'update_output') {
|
||||
this.output += msg.data?.chunk || ''
|
||||
} else if (msg.type === 'update_done') {
|
||||
this.jobStatus = 'success'
|
||||
resolve()
|
||||
} else if (msg.type === 'update_error') {
|
||||
this.jobStatus = 'error'
|
||||
this.output += '\n[ERREUR] ' + (msg.data?.error || '')
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
this.ws.onerror = () => { this.jobStatus = 'error'; resolve() }
|
||||
this.ws.onclose = () => { if (this.jobStatus === 'running') { this.jobStatus = 'error'; resolve() } }
|
||||
})
|
||||
},
|
||||
|
||||
get totalPackages() {
|
||||
return this.targets.reduce((sum, t) => sum + (t.packages?.length || 0), 0)
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: settingsPage ─────────────────────────────────────────────
|
||||
Alpine.data('settingsPage', () => ({
|
||||
tab: 'general',
|
||||
loading: true,
|
||||
saving: false,
|
||||
saved: false,
|
||||
error: '',
|
||||
settings: {
|
||||
instance_name: '',
|
||||
public_url: '',
|
||||
default_lang: 'fr',
|
||||
ssh_host: '',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
proxmox_url: '',
|
||||
proxmox_token_id: '',
|
||||
proxmox_token_secret: '',
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await apiFetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
Object.assign(this.settings, data)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.saving = true
|
||||
this.saved = false
|
||||
this.error = ''
|
||||
try {
|
||||
const res = await apiFetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(this.settings),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
throw new Error(d.error || 'Erreur sauvegarde')
|
||||
}
|
||||
this.saved = true
|
||||
setTimeout(() => { this.saved = false }, 3000)
|
||||
} catch(e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
// ── Composant: modulesPage ──────────────────────────────────────────────
|
||||
Alpine.data('modulesPage', () => ({
|
||||
modules: [],
|
||||
loading: true,
|
||||
toggling: {},
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await apiFetch('/api/modules')
|
||||
if (res.ok) {
|
||||
this.modules = await res.json() || []
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async toggle(mod) {
|
||||
this.toggling[mod.id] = true
|
||||
try {
|
||||
const action = mod.enabled ? 'disable' : 'enable'
|
||||
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
mod.enabled = !mod.enabled
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.toggling[mod.id] = false
|
||||
}
|
||||
},
|
||||
|
||||
t(key) { return Alpine.store('i18n').t(key) },
|
||||
}))
|
||||
|
||||
}) // end alpine:init
|
||||
|
||||
// ── DOMContentLoaded : init stores + Swup ─────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Init stores
|
||||
await Alpine.store('i18n').init()
|
||||
await Alpine.store('auth').init()
|
||||
Alpine.store('ui').init()
|
||||
|
||||
// Guard auth : redirect si non authentifié
|
||||
const publicPages = ['login', 'install', 'index', '']
|
||||
const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
|
||||
|
||||
if (!publicPages.includes(currentPage)) {
|
||||
if (!Alpine.store('auth').isAuthenticated) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect depuis index
|
||||
if (currentPage === 'index' || currentPage === '') {
|
||||
const res = await fetch('/api/install/status').catch(() => null)
|
||||
if (res && res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
window.location.href = data.installed ? '/login.html' : '/install.html'
|
||||
} else {
|
||||
window.location.href = '/login.html'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Init Swup pour transitions de page
|
||||
if (typeof Swup !== 'undefined') {
|
||||
const swup = new Swup({
|
||||
containers: ['#swup'],
|
||||
animationSelector: '[class*="transition-"]',
|
||||
})
|
||||
|
||||
window.swup = swup
|
||||
|
||||
// Guard auth sur navigation
|
||||
swup.hooks.on('visit:start', (visit) => {
|
||||
const dest = new URL(visit.to.url, location.href).pathname
|
||||
.replace(/^\/|\.html$/g, '') || 'index'
|
||||
if (!publicPages.includes(dest) && !Alpine.store('auth').isAuthenticated) {
|
||||
visit.abort()
|
||||
window.location.href = '/login.html'
|
||||
}
|
||||
})
|
||||
|
||||
// Destroy Alpine scope de l'ancien contenu AVANT le swap
|
||||
swup.hooks.on('animation:out:end', () => {
|
||||
const container = document.getElementById('swup')
|
||||
if (container && typeof Alpine.destroyTree === 'function') {
|
||||
Alpine.destroyTree(container)
|
||||
}
|
||||
})
|
||||
|
||||
// Init Alpine sur le nouveau contenu APRÈS le swap
|
||||
swup.hooks.on('content:replace', () => {
|
||||
const container = document.getElementById('swup')
|
||||
if (container) {
|
||||
Alpine.initTree(container)
|
||||
}
|
||||
// Update current page dans UI store
|
||||
Alpine.store('ui').currentPage =
|
||||
window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
|
||||
})
|
||||
}
|
||||
|
||||
// HTMX : inject Authorization header sur toutes les requêtes
|
||||
document.addEventListener('htmx:configRequest', (e) => {
|
||||
const token = localStorage.getItem('pxp_token')
|
||||
if (token) {
|
||||
e.detail.headers['Authorization'] = 'Bearer ' + token
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue