added notification toasts (error modals still broken)
This commit is contained in:
141
internal/handlers/notifications.go
Normal file
141
internal/handlers/notifications.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
// NotificationType defines the type of notification
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationSuccess NotificationType = "success"
|
||||
NotificationWarning NotificationType = "warning"
|
||||
NotificationInfo NotificationType = "info"
|
||||
)
|
||||
|
||||
// Notification represents a toast notification (success, warning, info)
|
||||
type Notification struct {
|
||||
Type NotificationType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorModal represents a full-screen error modal (500, 503)
|
||||
type ErrorModal struct {
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details"`
|
||||
}
|
||||
|
||||
// setHXTrigger sets the HX-Trigger header with JSON-encoded data
|
||||
func setHXTrigger(w http.ResponseWriter, event string, data any) {
|
||||
payload := map[string]any{
|
||||
event: data,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// Fallback if JSON encoding fails
|
||||
w.Header().Set("HX-Trigger", event)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Trigger", string(jsonData))
|
||||
}
|
||||
|
||||
// formatErrorDetails extracts and formats error details from wrapped errors
|
||||
func formatErrorDetails(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use %+v format to get stack trace from github.com/pkg/errors
|
||||
return fmt.Sprintf("%+v", err)
|
||||
}
|
||||
|
||||
// notifyToast sends a toast notification via HX-Trigger header
|
||||
func notifyToast(
|
||||
w http.ResponseWriter,
|
||||
notifType NotificationType,
|
||||
title string,
|
||||
message string,
|
||||
) {
|
||||
notification := Notification{
|
||||
Type: notifType,
|
||||
Title: title,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
setHXTrigger(w, "showNotification", notification)
|
||||
}
|
||||
|
||||
// notifyErrorModal sends a full-screen error modal via HX-Trigger header
|
||||
func notifyErrorModal(
|
||||
s *hws.Server,
|
||||
w http.ResponseWriter,
|
||||
statusCode int,
|
||||
title string,
|
||||
message string,
|
||||
err error,
|
||||
) {
|
||||
modal := ErrorModal{
|
||||
Code: statusCode,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Details: formatErrorDetails(err),
|
||||
}
|
||||
|
||||
// Log the error
|
||||
s.LogError(hws.HWSError{
|
||||
StatusCode: statusCode,
|
||||
Message: message,
|
||||
Error: err,
|
||||
Level: hws.ErrorERROR,
|
||||
})
|
||||
|
||||
// Set response status
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
// Send notification via HX-Trigger
|
||||
setHXTrigger(w, "showErrorModal", modal)
|
||||
}
|
||||
|
||||
// notifySuccess sends a success toast notification
|
||||
func notifySuccess(w http.ResponseWriter, title string, message string) {
|
||||
notifyToast(w, NotificationSuccess, title, message)
|
||||
}
|
||||
|
||||
// notifyWarning sends a warning toast notification
|
||||
func notifyWarning(w http.ResponseWriter, title string, message string) {
|
||||
notifyToast(w, NotificationWarning, title, message)
|
||||
}
|
||||
|
||||
// notifyInfo sends an info toast notification
|
||||
func notifyInfo(w http.ResponseWriter, title string, message string) {
|
||||
notifyToast(w, NotificationInfo, title, message)
|
||||
}
|
||||
|
||||
// notifyInternalServiceError sends a 500 error modal
|
||||
func notifyInternalServiceError(
|
||||
s *hws.Server,
|
||||
w http.ResponseWriter,
|
||||
message string,
|
||||
err error,
|
||||
) {
|
||||
notifyErrorModal(s, w, 500, "Internal Server Error", message, err)
|
||||
}
|
||||
|
||||
// notifyServiceUnavailable sends a 503 error modal
|
||||
func notifyServiceUnavailable(
|
||||
s *hws.Server,
|
||||
w http.ResponseWriter,
|
||||
message string,
|
||||
err error,
|
||||
) {
|
||||
notifyErrorModal(s, w, 503, "Service Unavailable", message, err)
|
||||
}
|
||||
42
internal/handlers/test.go
Normal file
42
internal/handlers/test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
// Handles responses to the / path. Also serves a 404 Page for paths that
|
||||
// don't have explicit handlers
|
||||
func Test(server *hws.Server) http.Handler {
|
||||
return http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
page.Test().Render(r.Context(), w)
|
||||
} else {
|
||||
r.ParseForm()
|
||||
notifytype := r.Form.Get("type")
|
||||
title := r.Form.Get("title")
|
||||
message := r.Form.Get("message")
|
||||
switch notifytype {
|
||||
case "error":
|
||||
err := errors.New(message)
|
||||
notifyInternalServiceError(server, w, title, err)
|
||||
return
|
||||
case "warn":
|
||||
notifyWarning(w, title, message)
|
||||
return
|
||||
case "info":
|
||||
notifyInfo(w, title, message)
|
||||
return
|
||||
case "success":
|
||||
notifySuccess(w, title, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package popup
|
||||
|
||||
templ Error503Popup() {
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showError503"
|
||||
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
|
||||
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-x-[100%]"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="opacity-0 duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-[100%]"
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
class="rounded-sm bg-dark-red p-4"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2 text-red w-fit">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
|
||||
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
|
||||
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
|
||||
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
|
||||
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<strong class="block font-medium">Service Unavailable</strong>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6 text-subtext0 hover:cursor-pointer"
|
||||
@click="showError503=false"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-red">
|
||||
The service is currently available. It could be down for maintenance.
|
||||
Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
126
internal/view/component/popup/errorModal.templ
Normal file
126
internal/view/component/popup/errorModal.templ
Normal file
@@ -0,0 +1,126 @@
|
||||
package popup
|
||||
|
||||
// ErrorModal displays a full-screen modal for critical errors (500, 503)
|
||||
templ ErrorModal() {
|
||||
<div
|
||||
x-show="errorModal.show"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
>
|
||||
<!-- Backdrop (not clickable) -->
|
||||
<div class="absolute inset-0 bg-crust/80"></div>
|
||||
<!-- Modal Card -->
|
||||
<div
|
||||
class="relative bg-surface0 border-2 border-dark-red rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
x-transition:enter="transition ease-out duration-300 delay-100"
|
||||
x-transition:enter-start="opacity-0 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="error-modal-title"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between p-6 border-b border-dark-red">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Warning Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-8 text-red flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<!-- Title -->
|
||||
<h2
|
||||
id="error-modal-title"
|
||||
class="text-2xl font-bold text-red"
|
||||
x-text="`${errorModal.code} - ${errorModal.title}`"
|
||||
></h2>
|
||||
</div>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="closeErrorModal()"
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div class="p-6">
|
||||
<!-- User Message -->
|
||||
<p class="text-lg text-subtext0" x-text="errorModal.message"></p>
|
||||
<!-- Details Dropdown -->
|
||||
<div class="mt-6">
|
||||
<details class="bg-mantle rounded-lg p-4">
|
||||
<summary class="cursor-pointer text-subtext1 font-semibold select-none hover:text-text transition">
|
||||
Details
|
||||
<span class="text-xs text-subtext0 ml-2">(click to expand)</span>
|
||||
</summary>
|
||||
<div class="mt-4 relative">
|
||||
<pre
|
||||
id="error-modal-details"
|
||||
class="text-xs text-subtext0 font-mono whitespace-pre-wrap break-all bg-surface0 p-4 rounded overflow-x-auto max-h-96"
|
||||
x-text="errorModal.details"
|
||||
></pre>
|
||||
</div>
|
||||
<button
|
||||
onclick="copyToClipboard('error-modal-details', this)"
|
||||
class="mt-2 bg-mauve text-crust px-3 py-1 rounded text-xs hover:bg-mauve/75 transition"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Copy to Clipboard Script -->
|
||||
<script>
|
||||
function copyToClipboard(elementId, button) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.innerText;
|
||||
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
const originalText = button.innerText;
|
||||
button.innerText = 'Copied!';
|
||||
setTimeout(() => {
|
||||
button.innerText = originalText;
|
||||
}, 2000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
button.innerText = 'Failed';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package popup
|
||||
|
||||
templ Error500Popup() {
|
||||
templ ErrorPopup() {
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showError500"
|
||||
10
internal/view/component/popup/toastContainer.templ
Normal file
10
internal/view/component/popup/toastContainer.templ
Normal file
@@ -0,0 +1,10 @@
|
||||
package popup
|
||||
|
||||
// ToastContainer displays stacked toast notifications (success, warning, info)
|
||||
templ ToastContainer() {
|
||||
<div class="fixed top-20 right-5 z-40 flex flex-col gap-3 max-w-sm pointer-events-none">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
@ToastNotification()
|
||||
</template>
|
||||
</div>
|
||||
}
|
||||
114
internal/view/component/popup/toastNotification.templ
Normal file
114
internal/view/component/popup/toastNotification.templ
Normal file
@@ -0,0 +1,114 @@
|
||||
package popup
|
||||
|
||||
// ToastNotification displays an individual toast notification
|
||||
templ ToastNotification() {
|
||||
<div
|
||||
class="pointer-events-auto rounded-lg shadow-lg overflow-hidden w-full"
|
||||
:class="{
|
||||
'bg-dark-green border-2 border-green': toast.type === 'success',
|
||||
'bg-dark-yellow border-2 border-yellow': toast.type === 'warning',
|
||||
'bg-dark-blue border-2 border-blue': toast.type === 'info'
|
||||
}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-full"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-full"
|
||||
@mouseenter="toast.paused = true"
|
||||
@mouseleave="toast.paused = false"
|
||||
role="alert"
|
||||
>
|
||||
<!-- Toast Content -->
|
||||
<div class="p-4">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<!-- Icon + Content -->
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<!-- Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5 flex-shrink-0"
|
||||
:class="{
|
||||
'text-green': toast.type === 'success',
|
||||
'text-yellow': toast.type === 'warning',
|
||||
'text-blue': toast.type === 'info'
|
||||
}"
|
||||
>
|
||||
<!-- Success Icon (checkmark) -->
|
||||
<template x-if="toast.type === 'success'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
<!-- Warning Icon (exclamation) -->
|
||||
<template x-if="toast.type === 'warning'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
<!-- Info Icon (information) -->
|
||||
<template x-if="toast.type === 'info'">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</template>
|
||||
</svg>
|
||||
<!-- Text Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-semibold"
|
||||
:class="{
|
||||
'text-green': toast.type === 'success',
|
||||
'text-yellow': toast.type === 'warning',
|
||||
'text-blue': toast.type === 'info'
|
||||
}"
|
||||
x-text="toast.title"
|
||||
></p>
|
||||
<p class="text-sm text-subtext0 mt-1" x-text="toast.message"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="text-subtext0 hover:text-text transition flex-shrink-0"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-1 bg-surface0">
|
||||
<div
|
||||
class="h-full"
|
||||
:class="{
|
||||
'bg-green': toast.type === 'success',
|
||||
'bg-yellow': toast.type === 'warning',
|
||||
'bg-blue': toast.type === 'info'
|
||||
}"
|
||||
:style="`width: ${toast.progress}%`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -34,35 +34,139 @@ templ Global(title string) {
|
||||
htmx.logAll();
|
||||
</script>
|
||||
}
|
||||
<script>
|
||||
const bodyData = {
|
||||
showError500: false,
|
||||
showError503: false,
|
||||
// handle errors from the server on HTMX requests
|
||||
handleHtmxError(event) {
|
||||
const errorCode = event.detail.errorInfo.error;
|
||||
|
||||
// internal server error
|
||||
if (errorCode.includes("Code 500")) {
|
||||
this.showError500 = true;
|
||||
setTimeout(() => (this.showError500 = false), 6000);
|
||||
<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;
|
||||
}
|
||||
// service not available error
|
||||
if (errorCode.includes("Code 503")) {
|
||||
this.showError503 = true;
|
||||
setTimeout(() => (this.showError503 = false), 6000);
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
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:error="handleHtmxError($event)"
|
||||
>
|
||||
@popup.Error500Popup()
|
||||
@popup.Error503Popup()
|
||||
<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()
|
||||
<div
|
||||
id="main-content"
|
||||
class="flex flex-col h-screen justify-between"
|
||||
|
||||
71
internal/view/page/test.templ
Normal file
71
internal/view/page/test.templ
Normal file
@@ -0,0 +1,71 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
|
||||
// Page content for the test notification page
|
||||
templ Test() {
|
||||
@layout.Global("Notification Test") {
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-200px)]">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<!-- Title -->
|
||||
<h1 class="text-4xl font-bold text-center text-text mb-8">
|
||||
Notification Test
|
||||
</h1>
|
||||
<!-- Form Card -->
|
||||
<div class="bg-surface0 rounded-lg shadow-lg p-6 border border-overlay0">
|
||||
<form hx-post="/test" hx-swap="none" class="flex flex-col gap-4">
|
||||
<!-- Notification Type -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="type" class="text-sm font-semibold text-subtext1">
|
||||
Notification Type
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
id="type"
|
||||
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
|
||||
>
|
||||
<option value="error">Error Modal</option>
|
||||
<option value="warn">Warning Toast</option>
|
||||
<option value="info">Info Toast</option>
|
||||
<option value="success">Success Toast</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Title Input -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="title" class="text-sm font-semibold text-subtext1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
placeholder="Enter notification title"
|
||||
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
|
||||
/>
|
||||
</div>
|
||||
<!-- Message Input -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="message" class="text-sm font-semibold text-subtext1">
|
||||
Message
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="message"
|
||||
id="message"
|
||||
placeholder="Enter notification message"
|
||||
class="bg-base text-text border border-overlay0 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-mauve"
|
||||
/>
|
||||
</div>
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 bg-mauve text-crust font-semibold px-6 py-3 rounded-lg hover:bg-mauve/75 transition"
|
||||
>
|
||||
Send Notification
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user