Files
bookhoard/web/src/toast.ts
T
John O'Keefe f8de17723a feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
2026-10-23 11:06:42 -04:00

288 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Toast notification system for backend errors
// Displays toast notifications at the top of the page
type ToastType = "error" | "success" | "info" | "warning";
const TOAST_DEFAULT_DURATION = 5000;
// Create toast container
const createToastContainer = (): HTMLElement => {
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
container.className = "fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 pointer-events-none";
document.body.appendChild(container);
}
return container;
};
// Escape HTML to prevent XSS
const toastEscapeHtml = (text: string): string => {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
};
// Get toast configuration by type
const getToastConfig = (type: ToastType) => {
const configs = {
error: {
bgClass: "bg-red-500/90",
icon: "❌",
},
success: {
bgClass: "bg-green-500/90",
icon: "✅",
},
info: {
bgClass: "bg-blue-500/90",
icon: "ℹ️",
},
warning: {
bgClass: "bg-yellow-500/90",
icon: "⚠️",
},
};
return configs[type];
};
// Create a toast element
const createToastElement = (
message: string,
type: ToastType,
action?: { label: string; onClick: () => void },
): HTMLElement => {
const toast = document.createElement("div");
const config = getToastConfig(type);
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
const actionHtml = action
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
: "";
toast.innerHTML = `
<span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
${actionHtml}
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
×
</button>
`;
// Add close button handler
const closeBtn = toast.querySelector(".toast-close") as HTMLElement;
if (closeBtn) {
closeBtn.onclick = () => removeToast(toast);
}
// Action button: run the handler and dismiss (an action consumes the
// toast — leaving it up after acting invites double-fires).
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
if (actionBtn && action) {
actionBtn.onclick = () => {
removeToast(toast);
action.onClick();
};
}
return toast;
};
// Trigger toast animation
const animateToastIn = (toast: HTMLElement): void => {
setTimeout(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
}, 10);
};
// Remove toast with animation
const removeToast = (toast: HTMLElement): void => {
toast.style.opacity = "0";
toast.style.transform = "translateY(-20px)";
setTimeout(() => {
if (toast.parentElement) {
toast.parentElement.removeChild(toast);
}
}, 300);
};
// Show toast notification. `action` (optional): a labeled button for
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
// dismisses the toast.
const showToast = (
message: string,
type: ToastType,
duration: number = TOAST_DEFAULT_DURATION,
action?: { label: string; onClick: () => void },
): void => {
const container = createToastContainer();
const toast = createToastElement(message, type, action);
container.appendChild(toast);
animateToastIn(toast);
// Auto-remove after duration
setTimeout(() => {
removeToast(toast);
}, duration);
};
// Parse error from XHR response
const parseXHRError = (xhr: XMLHttpRequest): string => {
let errorMessage = "An error occurred";
try {
const response = JSON.parse(xhr.responseText);
errorMessage = response.error || response.message || errorMessage;
} catch {
errorMessage = xhr.responseText || errorMessage;
}
return errorMessage;
};
// Parse error from fetch response
const parseFetchError = async (response: Response): Promise<string> => {
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const data = await response.json();
return data.error || data.message || `Error ${response.status}`;
}
return `Error ${response.status}: ${response.statusText}`;
};
// Setup HTMX error listeners
const setupHTMXListeners = (): void => {
// Listen for HTMX afterSwap event to detect errors in swapped content
document.body.addEventListener("htmx:afterSwap", (evt: Event) => {
interface HTMXEventDetail {
xhr: XMLHttpRequest;
succeeded: boolean;
target: Element;
}
const customEvent = evt as CustomEvent<HTMXEventDetail>;
if (customEvent.detail.xhr) {
const xhr = customEvent.detail.xhr;
if (customEvent.detail.succeeded === false && xhr.status >= 400 && xhr.status < 600) {
const errorMessage = parseXHRError(xhr);
showToast(errorMessage, "error");
} else if (xhr.status >= 200 && xhr.status < 300) {
const contentType = xhr.getResponseHeader("content-type");
if (contentType && contentType.includes("application/json")) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
showToast(response.message, "success");
if (customEvent.detail.target) {
customEvent.detail.target.innerHTML = "";
}
}
} catch {
// Not valid JSON — ignore
}
}
}
}
});
// Also listen for response errors (network issues, invalid responses)
document.body.addEventListener("htmx:responseError", (evt: Event) => {
const customEvent = evt as CustomEvent<{ xhr: XMLHttpRequest }>;
const xhr = customEvent.detail.xhr;
const errorMessage = parseXHRError(xhr);
showToast(errorMessage, "error");
});
};
// Suggestion fetches — field-value autocomplete on the search endpoint
// (author=, genre=, note_text=, collection=, … any filter-registry
// field key) — are background hints for typed inputs. A failed hint
// must never interrupt the user with a toast: it logs to the console
// only. The grid's real search (q=/sort= params) is NOT a suggestion
// fetch and keeps its toasts.
const suggestionFieldRe =
/[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/;
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
// The builder's live-count endpoint is a background hint too — a
// failed count must never interrupt with a toast.
if (
!url.includes("/api/media-items/search?") &&
!url.includes("/api/media-items/filter-count?")
) {
return false;
}
if (url.includes("/api/media-items/filter-count?")) return true;
return suggestionFieldRe.test(url) && !/[?&](q|sort)=/.test(url);
}
// Setup fetch interceptor
const setupFetchInterceptor = (): void => {
const originalFetch = window.fetch;
window.fetch = async (...args: Parameters<typeof fetch>): Promise<Response> => {
try {
const response = await originalFetch(...args);
// Special handling for 401 Unauthorized
if (response.status === 401) {
// Clear invalid tokens from localStorage
localStorage.removeItem("token");
localStorage.removeItem("refreshToken");
localStorage.removeItem("user");
// Check if this was a page navigation (not API call)
const url = args[0] as string;
// Don't show toast for page navigations - will be handled by redirect
if (!url.startsWith("/api/")) {
// Direct navigation to protected page will be caught by middleware
// Just throw to prevent further processing
throw new Error("Session expired");
}
// API call - show toast error
const errorMessage = await parseFetchError(response);
showToast(errorMessage, "error");
return response;
}
// Handle other errors. Suggestion fetches are exempt (console
// only) — a failed background hint never interrupts.
if (!response.ok) {
const errorMessage = await parseFetchError(response);
if (isSuggestionFetch(args[0])) {
console.warn(`Suggestion fetch failed (${response.status}): ${errorMessage}`);
} else {
showToast(errorMessage, "error");
}
}
return response;
} catch (error) {
// Don't show toast for redirect errors
if ((error as Error).message !== "Session expired") {
if (isSuggestionFetch(args[0])) {
console.warn("Suggestion fetch failed: network error", error);
} else {
showToast("Network error: Unable to connect to server", "error");
}
}
throw error;
}
};
};
// Initialize toast system
const initializeToastSystem = (): void => {
setupHTMXListeners();
setupFetchInterceptor();
};
initializeToastSystem();
export { showToast, getToastConfig };
export type { ToastType };