we now got websockets baby

This commit is contained in:
2026-01-26 00:29:57 +11:00
parent 959ca96b68
commit eccab815ae
23 changed files with 621 additions and 312 deletions

View File

@@ -8,13 +8,11 @@ import "git.haelnorr.com/h/oslstats/pkg/contexts"
// Global page layout. Includes HTML document settings, header tags
// navbar and footer
templ Global(title string) {
{{ htmxLogging := contexts.HTMXLog(ctx) }}
{{ devInfo := contexts.DevMode(ctx) }}
<!DOCTYPE html>
<html
lang="en"
x-data="{
theme: localStorage.getItem('theme')
|| 'system'}"
x-data="{ theme: localStorage.getItem('theme') || 'system'}"
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
@@ -26,147 +24,23 @@ templ Global(title string) {
<title>{ title }</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
<link href="/static/css/output.css" rel="stylesheet"/>
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
if htmxLogging {
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
<script src="/static/js/toasts.js" defer></script>
<script src="/static/vendored/alpinejs@3.15.4.min.js" defer></script>
if devInfo.HTMXLog {
<script>
htmx.logAll();
</script>
}
<script>
const bodyData = {
// Error modal (blocking, full-screen) - for 500/503 errors
errorModal: {
show: false,
code: 0,
title: '',
message: '',
details: ''
},
// Toast notifications (non-blocking, stacked) - for success/warning/info
toasts: [],
toastIdCounter: 0,
// Handle HTMX beforeSwap event - intercept both errors and successes
handleBeforeSwap(event) {
const xhr = event.detail.xhr;
if (!xhr) return;
const status = xhr.status;
const trigger = xhr.getResponseHeader('HX-Trigger');
if (!trigger) return;
try {
const data = JSON.parse(trigger);
// Handle 500/503 error modals
if ((status === 500 || status === 503) && data.showErrorModal) {
this.errorModal = {
show: true,
code: data.showErrorModal.code,
title: data.showErrorModal.title,
message: data.showErrorModal.message,
details: data.showErrorModal.details
};
// Prevent swap but allow error logging
event.detail.shouldSwap = false;
return;
}
// Handle success/warning/info toasts (200-299)
if (status >= 200 && status < 300 && data.showNotification) {
this.addToast(data.showNotification);
}
} catch (e) {
console.error('Failed to parse HX-Trigger:', e);
}
},
// Add toast to stack
addToast(notification) {
const toast = {
id: ++this.toastIdCounter,
type: notification.type,
title: notification.title,
message: notification.message,
paused: false,
progress: 0
};
this.toasts.push(toast);
// Determine timeout based on type
const timeout = notification.type === 'warning' ? 5000 : 3000;
// Start progress animation
this.animateToastProgress(toast.id, timeout);
},
// Animate toast progress bar and auto-dismiss
animateToastProgress(toastId, duration) {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return;
const startTime = performance.now();
let totalPausedTime = 0;
let pauseStartTime = null;
const animate = (currentTime) => {
const toast = this.toasts.find(t => t.id === toastId);
if (!toast) return; // Toast was manually removed
if (toast.paused) {
// Track when pause started
if (pauseStartTime === null) {
pauseStartTime = currentTime;
}
// Keep animating while paused
requestAnimationFrame(animate);
return;
} else {
// If we were paused, accumulate the paused time
if (pauseStartTime !== null) {
totalPausedTime += currentTime - pauseStartTime;
pauseStartTime = null;
}
}
// Calculate actual elapsed time (excluding paused time)
const elapsed = currentTime - startTime - totalPausedTime;
toast.progress = Math.min((elapsed / duration) * 100, 100);
if (elapsed >= duration) {
this.removeToast(toastId);
} else {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
},
// Remove toast from stack
removeToast(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
},
// Close error modal
closeErrorModal() {
this.errorModal.show = false;
}
};
</script>
</head>
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
x-data="bodyData"
x-on:htmx:before-swap="handleBeforeSwap($event)"
x-on:keydown.escape.window="closeErrorModal()"
>
@popup.ErrorModal()
@popup.ToastContainer()
<body
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
hx-ext="ws"
ws-connect={ devInfo.WebsocketBase + "/ws/notifications" }
>
<!-- @popup.ErrorModal() -->
@popup.ToastContainer()
<div
id="main-content"
class="flex flex-col h-screen justify-between"