286 lines
8.5 KiB
JavaScript
286 lines
8.5 KiB
JavaScript
/**
|
||
* Toast-уведомления зоотех-страниц (нижний pill-toast, стиль Яндекс Музыки).
|
||
*/
|
||
(function (global) {
|
||
const HOST_ID = "wespZtToastHost";
|
||
const DEFAULT_DURATION_MS = 4000;
|
||
const EXIT_MS = 220;
|
||
|
||
const UM = () => global.WespUserMessages || {};
|
||
const userFacingMessage = (message, fallback) => {
|
||
const api = UM();
|
||
if (typeof api.userFacingMessage === "function") {
|
||
return api.userFacingMessage(message, fallback);
|
||
}
|
||
const text = message == null ? "" : String(message).trim();
|
||
return text || fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
|
||
};
|
||
|
||
let hostEl = null;
|
||
let activeToast = null;
|
||
let dismissTimer = null;
|
||
|
||
function ensureHost() {
|
||
if (hostEl && hostEl.isConnected) return hostEl;
|
||
hostEl = document.getElementById(HOST_ID);
|
||
if (!hostEl) {
|
||
hostEl = document.createElement("div");
|
||
hostEl.id = HOST_ID;
|
||
hostEl.className = "wesp-zt-toast-host";
|
||
hostEl.setAttribute("aria-live", "polite");
|
||
hostEl.setAttribute("aria-atomic", "true");
|
||
document.body.appendChild(hostEl);
|
||
}
|
||
return hostEl;
|
||
}
|
||
|
||
function clearDismissTimer() {
|
||
if (dismissTimer) {
|
||
clearTimeout(dismissTimer);
|
||
dismissTimer = null;
|
||
}
|
||
}
|
||
|
||
function normalizeType(type) {
|
||
if (type === "error" || type === "warning") return type;
|
||
return "success";
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function normalizeDisplayMessage(message) {
|
||
if (message && typeof message === "object") {
|
||
if (Array.isArray(message.parts)) return message;
|
||
if (message.text != null) message = message.text;
|
||
}
|
||
const text = message == null ? "" : String(message).trim();
|
||
if (/^Сохранено\b/i.test(text)) {
|
||
return "Сохранено";
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function renderMessageContent(message, opts) {
|
||
if (message && typeof message === "object") {
|
||
if (Array.isArray(message.parts)) {
|
||
return message.parts
|
||
.map((part) => {
|
||
const text = escapeHtml(part && part.text != null ? part.text : "");
|
||
return part && part.bold ? `<strong>${text}</strong>` : text;
|
||
})
|
||
.join("");
|
||
}
|
||
if (message.text != null) {
|
||
return escapeHtml(message.text);
|
||
}
|
||
}
|
||
|
||
const text = message == null ? "" : String(message);
|
||
if (opts && Array.isArray(opts.parts)) {
|
||
return opts.parts
|
||
.map((part) => {
|
||
const chunk = escapeHtml(part && part.text != null ? part.text : "");
|
||
return part && part.bold ? `<strong>${chunk}</strong>` : chunk;
|
||
})
|
||
.join("");
|
||
}
|
||
return escapeHtml(text);
|
||
}
|
||
|
||
function removeToast(toastEl, immediate) {
|
||
if (!toastEl || !toastEl.isConnected) return Promise.resolve();
|
||
clearDismissTimer();
|
||
if (immediate) {
|
||
toastEl.remove();
|
||
if (activeToast === toastEl) activeToast = null;
|
||
return Promise.resolve();
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
toastEl.classList.add("is-exiting");
|
||
const onEnd = () => {
|
||
toastEl.removeEventListener("animationend", onEnd);
|
||
toastEl.remove();
|
||
if (activeToast === toastEl) activeToast = null;
|
||
resolve();
|
||
};
|
||
toastEl.addEventListener("animationend", onEnd);
|
||
setTimeout(onEnd, EXIT_MS + 40);
|
||
});
|
||
}
|
||
|
||
function dismissAll() {
|
||
clearDismissTimer();
|
||
if (!activeToast) return;
|
||
const toast = activeToast;
|
||
activeToast = null;
|
||
return removeToast(toast, false);
|
||
}
|
||
|
||
function plainTextFromMessage(message) {
|
||
const api = UM();
|
||
if (typeof api.plainText === "function") return api.plainText(message);
|
||
if (message && typeof message === "object") {
|
||
if (message instanceof Error) return "";
|
||
if (Array.isArray(message.parts)) {
|
||
return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join("");
|
||
}
|
||
if (message.text != null) return String(message.text);
|
||
}
|
||
return message == null ? "" : String(message);
|
||
}
|
||
|
||
function persistNotification(type, message, opts) {
|
||
if (opts && opts.skipRecord) return;
|
||
const plain = plainTextFromMessage(
|
||
message && typeof message === "object" && Array.isArray(message.parts)
|
||
? message
|
||
: normalizeDisplayMessage(message)
|
||
);
|
||
if (!plain.trim() && !(opts && opts.record)) return;
|
||
|
||
const builder = global.WespZootechNotificationMessages;
|
||
const payload = builder
|
||
? builder.buildNotificationRecord(type, plain, opts)
|
||
: {
|
||
title: plain.slice(0, 200) || "Уведомление",
|
||
detail: plain,
|
||
kind: type,
|
||
category: "general",
|
||
page: "general",
|
||
};
|
||
|
||
fetch("/api/notifications", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
.then(() => {
|
||
global.WespZootechNotificationCenter?.refreshBadge?.();
|
||
})
|
||
.catch(() => {
|
||
/* fire-and-forget */
|
||
});
|
||
}
|
||
|
||
function show(type, message, opts) {
|
||
const normalizedType = normalizeType(type);
|
||
const duration =
|
||
opts && Number.isFinite(opts.duration) ? opts.duration : DEFAULT_DURATION_MS;
|
||
const displayMessage =
|
||
message && typeof message === "object" && Array.isArray(message.parts)
|
||
? message
|
||
: normalizeDisplayMessage(message);
|
||
const html = renderMessageContent(displayMessage, opts);
|
||
const plainText =
|
||
displayMessage && typeof displayMessage === "object"
|
||
? ""
|
||
: String(displayMessage || "");
|
||
const isMultiline = plainText.length > 42 || /\n/.test(plainText);
|
||
|
||
const run = async () => {
|
||
if (activeToast) {
|
||
await removeToast(activeToast, false);
|
||
}
|
||
|
||
const root = ensureHost();
|
||
const toast = document.createElement("div");
|
||
toast.className = `wesp-zt-toast wesp-zt-toast--${normalizedType}${
|
||
isMultiline ? " wesp-zt-toast--multiline" : ""
|
||
}`;
|
||
toast.setAttribute("role", "status");
|
||
|
||
const accent = document.createElement("span");
|
||
accent.className = "wesp-zt-toast__accent";
|
||
accent.setAttribute("aria-hidden", "true");
|
||
|
||
const messageEl = document.createElement("p");
|
||
messageEl.className = "wesp-zt-toast__message";
|
||
messageEl.innerHTML = html;
|
||
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.type = "button";
|
||
closeBtn.className = "wesp-zt-toast__close";
|
||
closeBtn.setAttribute("aria-label", "Закрыть уведомление");
|
||
closeBtn.textContent = "×";
|
||
closeBtn.addEventListener("click", () => {
|
||
dismissAll();
|
||
});
|
||
|
||
toast.append(accent, messageEl, closeBtn);
|
||
root.appendChild(toast);
|
||
activeToast = toast;
|
||
|
||
if (duration > 0) {
|
||
dismissTimer = setTimeout(() => {
|
||
dismissAll();
|
||
}, duration);
|
||
}
|
||
};
|
||
|
||
persistNotification(normalizedType, displayMessage, opts);
|
||
run();
|
||
}
|
||
|
||
function success(message, opts) {
|
||
show("success", message, opts);
|
||
}
|
||
|
||
function error(message, opts) {
|
||
const safe = userFacingMessage(message, opts && opts.fallback);
|
||
show("error", safe, opts);
|
||
}
|
||
|
||
function warning(message, opts) {
|
||
const safe = userFacingMessage(message, opts && opts.fallback);
|
||
show("warning", safe, opts);
|
||
}
|
||
|
||
function open(payload) {
|
||
const data = payload && typeof payload === "object" ? payload : {};
|
||
show(data.type || "success", data.message, data);
|
||
}
|
||
|
||
function createAdapter() {
|
||
return {
|
||
success,
|
||
error,
|
||
warning,
|
||
open,
|
||
dismissAll,
|
||
};
|
||
}
|
||
|
||
global.WespZootechNotify = {
|
||
success,
|
||
error,
|
||
warning,
|
||
open,
|
||
dismissAll,
|
||
createAdapter,
|
||
userFacingMessage,
|
||
messageFromResponseBody: (body, fallback) => {
|
||
const api = UM();
|
||
if (typeof api.messageFromResponseBody === "function") {
|
||
return api.messageFromResponseBody(body, fallback);
|
||
}
|
||
return userFacingMessage(null, fallback);
|
||
},
|
||
isTechnicalMessage: (message) => {
|
||
const api = UM();
|
||
if (typeof api.isTechnicalMessage === "function") return api.isTechnicalMessage(message);
|
||
return false;
|
||
},
|
||
DEFAULT_ERROR_MESSAGE: UM().DEFAULT_ERROR_MESSAGE || "Не удалось выполнить операцию. Попробуйте ещё раз.",
|
||
get instance() {
|
||
return createAdapter();
|
||
},
|
||
};
|
||
})(typeof window !== "undefined" ? window : globalThis);
|