- 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.)
288 lines
9.6 KiB
TypeScript
288 lines
9.6 KiB
TypeScript
// 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 };
|