ui: toast per-type durations, Details modal for long errors, concise acme.sh failure summary

This commit is contained in:
2026-09-03 03:29:50 +00:00
parent 7e6fd71bdc
commit d78b90db00
5 changed files with 146 additions and 16 deletions
+38 -4
View File
@@ -107,19 +107,53 @@ export const _toasts = [];
const _toastIds = { next: 1 };
/**
* Show a toast notification. Auto-dismisses after `duration` ms.
* Default auto-dismiss durations per toast type (ms). 0 = never
* auto-dismiss. Errors stay on screen until dismissed so long
* failure messages remain readable.
*/
const _TOAST_DEFAULT_DURATIONS = {
info: 4000,
success: 4000,
warning: 8000,
error: 0,
};
/**
* Auto-dismiss timer that pauses while the toast is hovered.
* Re-checks in 1s while hovered instead of dismissing.
*/
function _scheduleToastDismiss(id, delay) {
setTimeout(() => {
const t = _toasts.find(t => t.id === id);
if (!t) return;
if (t.hovered) _scheduleToastDismiss(id, 1000);
else dismissToast(id);
}, delay);
}
/**
* Show a toast notification.
*
* When `duration` is omitted, per-type defaults apply: 'info' and
* 'success' auto-dismiss after 4000 ms, 'warning' after 8000 ms, and
* 'error' toasts never auto-dismiss. An explicit `duration` overrides
* the default. The auto-dismiss timer pauses while the toast is
* hovered.
*
* @param {string} message Toast text
* @param {string} [type] 'info' | 'success' | 'error' | 'warning'
* @param {number} [duration] Auto-dismiss timeout in ms (0 = indefinite)
* @returns {number} id
*/
export function toast(message, type = 'info', duration = 4000) {
export function toast(message, type = 'info', duration) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
const dur = duration === undefined
? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000)
: duration;
_toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false });
requestUpdate();
if (duration > 0) setTimeout(() => dismissToast(id), duration);
if (dur > 0) _scheduleToastDismiss(id, dur);
return id;
}
+41 -6
View File
@@ -3,10 +3,38 @@
*
* ToastContainer component that renders queued toast notifications.
* Uses the toast/dismissToast state from api.js.
*
* Long messages (>200 chars or containing newlines) render compact —
* first line with an ellipsis — plus a "Details" button that opens a
* modal with the full text. Dismissal is only via the × button;
* hovering the toast pauses its auto-dismiss timer.
*/
import { h } from '../vdom.js';
import { _toasts, dismissToast } from '../api.js';
import { openModal } from './modal.js';
/** Messages longer than this (or containing newlines) render compact. */
const _LONG_MESSAGE_CHARS = 200;
function _isLong(message) {
return message.length > _LONG_MESSAGE_CHARS || message.includes('\n');
}
function _firstLine(message) {
return message.split('\n')[0].trim();
}
function _showDetails(t) {
openModal(
h('div', null,
h('h2', { class: 'modal-title' }, 'Details'),
h('div', { class: 'modal-body' },
h('pre', { class: 'toast-details-msg' }, t.message),
),
),
);
}
/**
* Render all pending toast notifications.
@@ -24,19 +52,26 @@ export function ToastContainer() {
};
return h('div', { class: 'toast' },
..._toasts.map(t =>
h('div', {
..._toasts.map(t => {
const long = _isLong(t.message);
return h('div', {
class: `toast-message ${clsMap[t.type] || clsMap.info}`,
'on:click': () => dismissToast(t.id),
'on:mouseover': () => { t.hovered = true; },
'on:mouseout': () => { t.hovered = false; },
},
h('span', { class: 'toast-text' }, t.message),
h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') },
long ? _firstLine(t.message) : t.message),
h('div', { class: 'toast-actions' },
long ? h('button', {
class: 'toast-btn toast-details',
'on:click': (e) => { e.stopPropagation(); _showDetails(t); },
}, 'Details') : null,
h('button', {
class: 'toast-btn toast-close',
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
}, '\u00d7'),
),
),
),
);
}),
);
}