const state = { site: null, snippet: "", conversations: [], visitors: [], visitorsLoaded: false, activeId: null, active: null, events: null, statusFilter: "active", hideBots: localStorage.getItem("mf_admin_hide_bots") !== "0", replyFiles: [], attentionTimer: null, attentionOn: false, originalTitle: document.title, originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg" }; const $ = (selector) => document.querySelector(selector); boot(); async function boot() { const me = await api("/api/admin/me").catch(() => null); if (me?.user) showApp(); else showLogin(); } $("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); const result = await api("/api/admin/login", { method: "POST", body: { username: form.get("username"), password: form.get("password") } }).catch(() => null); if (!result?.ok) { $("#loginError").textContent = "Prihlaseni se nepodarilo."; return; } showApp(); }); $("#logoutButton").addEventListener("click", async () => { await api("/api/admin/logout", { method: "POST" }); location.reload(); }); $("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.remove("hidden")); $("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden")); $("#settingsForm").brand.addEventListener("input", (event) => { $("#settingsForm").brandHex.value = normalizeHexColor(event.target.value) || event.target.value; }); $("#settingsForm").brandHex.addEventListener("input", (event) => { const color = normalizeHexColor(event.target.value); if (color) $("#settingsForm").brand.value = color; }); window.addEventListener("focus", stopAttention); document.addEventListener("visibilitychange", () => { if (!document.hidden) stopAttention(); }); $("#onlineToggle").addEventListener("change", async () => { await saveSite({ isOnline: $("#onlineToggle").checked }); }); $("#settingsForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); const settings = structuredClone(state.site.settings); settings.title.cs = form.get("titleCs"); settings.intro.cs = form.get("introCs"); settings.title.en = form.get("titleEn"); settings.intro.en = form.get("introEn"); settings.colors.brand = normalizeHexColor(form.get("brandHex")) || form.get("brand"); settings.desktop.side = form.get("desktopSide"); settings.desktop.bottomPx = Number(form.get("desktopBottom")); settings.desktop.sideOffsetPx = Number(form.get("desktopOffset")); settings.mobile.side = form.get("mobileSide"); settings.mobile.bottomPx = Number(form.get("mobileBottom")); settings.mobile.sideOffsetPx = Number(form.get("mobileOffset")); settings.launcherEffects = { breathe: form.get("launcherBreathe") === "on", ring: form.get("launcherRing") === "on", hoverLabel: form.get("launcherHoverLabel") === "on", avatar: form.get("launcherAvatar") === "on" }; settings.notifications = settings.notifications || {}; settings.notifications.pulseOnAdminReply = form.get("messagePulse") === "on"; settings.notifications.badgeOnAdminReply = form.get("messageBadge") === "on"; settings.notifications.labelOnAdminReply = form.get("messageLabel") === "on"; settings.notifications.wiggleOnAdminReply = form.get("messageWiggle") === "on"; await saveSite({ settings, isOnline: state.site.isOnline }); $("#settingsPanel").classList.add("hidden"); }); $("#replyForm").addEventListener("submit", async (event) => { event.preventDefault(); if (!state.activeId) return; const replyForm = event.currentTarget; const textarea = replyForm.querySelector("textarea[name='message']"); const originalMessage = textarea.value; const form = new FormData(replyForm); const payload = { message: form.get("message") || "", attachments: await Promise.all(state.replyFiles.slice(0, 3).map(fileToPayload)) }; if (!payload.message.trim() && !payload.attachments.length) return; clearReplyComposer(replyForm); try { await api(`/api/admin/conversations/${state.activeId}/messages`, { method: "POST", body: payload }); clearReplyComposer(replyForm); setTimeout(() => clearReplyComposer(replyForm), 0); setTimeout(() => clearReplyComposer(replyForm), 120); state.replyFiles = []; renderReplyAttachments(); await openConversation(state.activeId); clearReplyComposer(replyForm); } catch (error) { textarea.value = originalMessage; textarea.defaultValue = originalMessage; throw error; } }); $("#replyForm textarea").addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); $("#replyForm").requestSubmit(); }); $("#replyForm textarea").addEventListener("input", debounce(() => { if (!state.activeId) return; api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {}); }, 600)); $("#replyForm input[name='attachments']").addEventListener("change", (event) => { addReplyFiles([...event.target.files]); event.target.value = ""; }); for (const eventName of ["dragenter", "dragover"]) { $(".conversation").addEventListener(eventName, (event) => { event.preventDefault(); if (!state.activeId) return; $("#replyForm").classList.add("dragging"); }); } for (const eventName of ["dragleave", "drop"]) { $(".conversation").addEventListener(eventName, (event) => { event.preventDefault(); $("#replyForm").classList.remove("dragging"); }); } $(".conversation").addEventListener("drop", (event) => { if (!state.activeId) return; addReplyFiles([...event.dataTransfer.files]); }); $(".conversation").addEventListener("paste", (event) => { if (!state.activeId) return; const files = [...event.clipboardData?.items || []] .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) .filter(Boolean); if (!files.length) return; event.preventDefault(); addReplyFiles(files); }); $("#replyAttachments").addEventListener("click", (event) => { const removeButton = event.target.closest("[data-remove-reply]"); if (!removeButton) return; state.replyFiles.splice(Number(removeButton.dataset.removeReply), 1); renderReplyAttachments(); }); $("#conversationFilter").addEventListener("change", () => { state.statusFilter = $("#conversationFilter").value; renderConversations(); }); $("#hideBotsToggle").addEventListener("click", () => { state.hideBots = !state.hideBots; localStorage.setItem("mf_admin_hide_bots", state.hideBots ? "1" : "0"); renderVisitors(); }); $("#conversationList").addEventListener("click", async (event) => { const photoButton = event.target.closest("[data-photo-url]"); const openButton = event.target.closest("[data-open]"); const deleteButton = event.target.closest("[data-delete]"); if (photoButton) { openPhotoPreview(photoButton.dataset.photoUrl, photoButton.dataset.photoName); return; } if (deleteButton) { await deleteConversation(deleteButton.dataset.delete); return; } if (openButton) await openConversation(openButton.dataset.open); }); $("#conversationList").addEventListener("click", async (event) => { const statusButton = event.target.closest("[data-status-button]"); if (!statusButton) return; await updateConversationStatus(statusButton.dataset.statusButton, statusButton.dataset.status); }); $("#messages").addEventListener("click", (event) => { const deleteButton = event.target.closest("[data-delete-attachment]"); if (deleteButton) { deleteAttachment(deleteButton.dataset.deleteAttachment); return; } const preview = event.target.closest("[data-photo-url]"); if (!preview) return; openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName); }); $("#visitorList").addEventListener("click", async (event) => { if (event.target.closest("a")) return; const card = event.target.closest("[data-visitor-conversation]"); const conversationId = card?.dataset.visitorConversation; if (!conversationId) return; await openConversation(conversationId); }); $("#conversationHeader").addEventListener("click", async (event) => { const button = event.target.closest("[data-rename-visitor]"); if (!button) return; event.preventDefault(); await renameVisitor(button.dataset.renameVisitor); }); async function showApp() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); await loadBootstrap(); await Promise.all([loadConversations(), loadVisitors()]); connectEvents(); } function showLogin() { $("#loginView").classList.remove("hidden"); $("#appView").classList.add("hidden"); } async function loadBootstrap() { const data = await api("/api/admin/bootstrap"); state.site = data.site; state.snippet = data.snippet; renderSite(); } async function loadConversations() { const data = await api("/api/admin/conversations"); state.conversations = data.conversations; syncConversationVisitors(); renderConversations(); } async function loadVisitors() { const data = await api("/api/admin/visitors"); state.visitors = data.visitors; state.visitorsLoaded = true; syncConversationVisitors(); renderConversations(); renderVisitors(); } async function openConversation(id) { stopAttention(); state.activeId = id; const data = await api(`/api/admin/conversations/${id}`); state.active = data; const index = state.conversations.findIndex((item) => item.id === id); if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation }; syncConversationVisitors(); renderConversations(); renderActive(); } function connectEvents() { if (state.events) state.events.close(); state.events = new EventSource("/api/admin/events"); state.events.addEventListener("message", async (event) => { const data = JSON.parse(event.data); if (data.type === "typing" && data.actor === "visitor" && data.conversationId !== state.activeId) return; if (data.type === "typing" && data.actor === "visitor") return flashTyping(); if (data.type === "site:update") { state.site = data.site; renderSite(); return; } if (data.type === "visitors:update") { state.visitors = data.visitors; state.visitorsLoaded = true; syncConversationVisitors(); renderConversations(); renderVisitors(); return; } await loadConversations(); if (data.type === "conversation:delete" && data.conversationId === state.activeId) { clearActiveConversation(); return; } if (state.activeId) await openConversation(state.activeId).catch(() => clearActiveConversation()); if (data.type === "conversation:new" || isVisitorMessageEvent(data)) notify(); }); } function renderSite() { $("#onlineToggle").checked = state.site.isOnline; $("#snippet").textContent = state.snippet; const s = state.site.settings; const form = $("#settingsForm"); form.titleCs.value = s.title.cs || ""; form.introCs.value = s.intro.cs || ""; form.titleEn.value = s.title.en || ""; form.introEn.value = s.intro.en || ""; form.brand.value = normalizeHexColor(s.colors.brand) || "#0f8f6f"; form.brandHex.value = form.brand.value; form.desktopSide.value = s.desktop.side || "right"; form.desktopBottom.value = s.desktop.bottomPx || 22; form.desktopOffset.value = s.desktop.sideOffsetPx || 22; form.mobileSide.value = s.mobile.side || "right"; form.mobileBottom.value = s.mobile.bottomPx || 14; form.mobileOffset.value = s.mobile.sideOffsetPx || 12; form.launcherBreathe.checked = s.launcherEffects?.breathe !== false; form.launcherRing.checked = s.launcherEffects?.ring !== false; form.launcherHoverLabel.checked = s.launcherEffects?.hoverLabel !== false; form.launcherAvatar.checked = s.launcherEffects?.avatar !== false; form.messagePulse.checked = s.notifications?.pulseOnAdminReply !== false; form.messageBadge.checked = s.notifications?.badgeOnAdminReply !== false; form.messageLabel.checked = s.notifications?.labelOnAdminReply !== false; form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== false; } function renderConversations() { const conversations = filteredConversations(); $("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
${statusButtons(item)}
`).join("") : `

Zadny chat pro vybrany filtr.

`; } function syncConversationVisitors() { if (!state.visitorsLoaded) return; const visitorsById = new Map(state.visitors.map((visitor) => [visitor.id, visitor])); state.conversations = state.conversations.map((item) => ({ ...item, visitorOnline: visitorsById.has(item.visitorToken), visitorPresence: visitorsById.get(item.visitorToken)?.presence || "offline" })); } function conversationVisitorName(item) { return item.visitorAlias || item.visitor?.name || item.visitor?.email || item.visitorLabel || "Navstevnik"; } function renderVisitors() { const visitors = state.hideBots ? state.visitors.filter((visitor) => !visitor.isBot) : state.visitors; const botCount = state.visitors.filter((visitor) => visitor.isBot).length; $("#hideBotsToggle").classList.toggle("active", state.hideBots); $("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt"; $("#visitorCount").textContent = `${visitors.length} na webu${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`; $("#visitorList").innerHTML = visitors.length ? visitors.map((visitor) => `
${escapeHtml(visitor.initials || "??")}
${escapeHtml(visitor.label || "Navstevnik")} ${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}
${presenceDot(visitor.presence)}
${visitorCountry(visitor)} ${formatDuration(visitor.sessionSeconds)} ${Number(visitor.visitCount || visitor.pageCount || 1)} navst. ${Number(visitor.conversationCount || 0)} chaty
${escapeHtml(pageTitle(visitor))}
${visitor.referrer ? escapeHtml(referrerLabel(visitor.referrer)) : "primy vstup"}${visitor.lastConversationId ? "" : " · bez chatu"}
`).join("") : `

Zatim nikdo online.

`; } function presenceDot(presence) { const value = ["active", "idle", "offline"].includes(presence) ? presence : "offline"; const label = value === "active" ? "Aktivni" : value === "idle" ? "Neaktivni" : "Offline"; return ``; } function pageTitle(visitor) { const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : []; const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1]; return current?.title || shortUrl(visitor.currentUrl) || "neznamá stranka"; } function referrerLabel(value) { try { return new URL(value).hostname.replace(/^www\./, ""); } catch { return shortUrl(value) || value; } } function browserLabel(device = {}) { const ua = String(device.userAgent || ""); const browser = ua.includes("Edg/") ? "Edge" : ua.includes("Chrome/") ? "Chrome" : ua.includes("Firefox/") ? "Firefox" : ua.includes("Safari/") ? "Safari" : "Browser"; return [device.platform || "zarizeni", browser].filter(Boolean).join(" - "); } function renderActive() { const { conversation, messages } = state.active; $("#conversationHeader").innerHTML = `
${presenceDot(conversation.visitorPresence)} ${visitorCountry(conversation)} ${formatDuration(conversation.sessionSeconds)} ${Number(conversation.pageCount || 0)} str. ${Number(conversation.visitorConversationCount || 1)}x chat ${visitorDetails(conversation)}
`; $("#replyForm").classList.remove("hidden"); $("#messages").innerHTML = messages.map((message) => `
${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}
${message.body ? `

${escapeHtml(message.body)}

` : ""} ${renderAttachments(message.attachments)}
`).join("") + adminReadReceipt(conversation, messages); $("#messages").scrollTop = $("#messages").scrollHeight; } async function renameVisitor(visitorToken) { if (!state.active?.conversation || !visitorToken) return; const currentName = conversationVisitorName(state.active.conversation); const nextName = window.prompt("Prejmenovat navstevnika", state.active.conversation.visitorAlias || currentName); if (nextName === null) return; const result = await api(`/api/admin/visitors/${encodeURIComponent(visitorToken)}`, { method: "PATCH", body: { displayName: nextName } }); applyVisitorRename(visitorToken, result.label, result.displayName); } function applyVisitorRename(visitorToken, label, alias) { const update = (item) => item.visitorToken === visitorToken ? { ...item, visitorLabel: label, visitorAlias: alias || "" } : item; state.conversations = state.conversations.map(update); state.visitors = state.visitors.map((visitor) => visitor.id === visitorToken ? { ...visitor, label, visitorAlias: alias || "" } : visitor); if (state.active?.conversation?.visitorToken === visitorToken) { state.active.conversation = update(state.active.conversation); } renderConversations(); renderVisitors(); if (state.active) renderActive(); } function adminReadReceipt(conversation, messages) { const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin"); if (!lastAdminMessage) return ""; const seenAt = conversation.visitorSeenAt; const isSeen = Boolean(seenAt && String(seenAt) >= String(lastAdminMessage.createdAt)); const text = isSeen ? `Precteno zakaznikem ${formatTime(seenAt)}` : "Zatim neprecteno"; return `
${escapeHtml(text)}
`; } function filteredConversations() { if (state.statusFilter === "all") return state.conversations; if (state.statusFilter === "active") return state.conversations.filter((item) => item.status === "new" || item.status === "open"); return state.conversations.filter((item) => item.status === state.statusFilter); } function statusButtons(item) { return [ ["new", "nove"], ["open", "otevrene"], ["resolved", "vyresene"] ].map(([value, label]) => ` `).join(""); } function visitorCountry(item) { const countryCode = item.countryCode || countryFromLanguage(item.language) || countryFromTimezone(item.timezone); const code = String(countryCode || "").toUpperCase(); if (!/^[A-Z]{2}$/.test(code)) return `??`; return ` ${escapeHtml(code)} `; } function visitorDetails(conversation) { const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : []; const historyItems = history.length ? history.slice().reverse().map((item) => `
  • ${escapeHtml(pageLabel(item))} ${escapeHtml(formatVisitedAt(item.at))}
  • `).join("") : `
  • Zatim bez historie.
  • `; return `
    Aktualne ${escapeHtml(shortUrl(conversation.currentUrl) || "-")} Prisiel z ${conversation.referrer ? `${escapeHtml(shortUrl(conversation.referrer))}` : `primy vstup / nezname`} Zarizeni ${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}
      ${historyItems}
    `; } function formatDuration(seconds) { const value = Number(seconds || 0); if (value < 60) return `${Math.max(0, value)}s`; const minutes = Math.floor(value / 60); if (minutes < 60) return `${minutes}m`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } async function updateConversationStatus(id, status) { await api(`/api/admin/conversations/${id}/status`, { method: "PATCH", body: { status } }); const item = state.conversations.find((conversation) => conversation.id === id); if (item) item.status = status; if (state.active?.conversation?.id === id) state.active.conversation.status = status; renderConversations(); } async function deleteConversation(id) { const item = state.conversations.find((conversation) => conversation.id === id); const label = item?.visitor?.name || item?.lastBody || "tento chat"; if (!confirm(`Smazat ${label}? Tahle akce nejde vratit.`)) return; await api(`/api/admin/conversations/${id}`, { method: "DELETE" }); state.conversations = state.conversations.filter((conversation) => conversation.id !== id); if (state.activeId === id) clearActiveConversation(); renderConversations(); } function clearActiveConversation() { state.activeId = null; state.active = null; $("#conversationHeader").innerHTML = `

    Vyber konverzaci

    `; $("#messages").innerHTML = ""; $("#replyForm").classList.add("hidden"); } function renderAttachments(attachments) { if (!attachments.length) return ""; return `
    ${attachments.map((attachment) => { if (!attachment.type?.startsWith("image/")) { return `${escapeHtml(attachment.name)}`; } return `
    ${escapeHtml(attachment.name)}
    `; }).join("")}
    `; } async function deleteAttachment(id) { if (!confirm("Smazat tuhle fotku?")) return; await api(`/api/admin/attachments/${id}`, { method: "DELETE" }); if (state.activeId) await openConversation(state.activeId); await loadConversations(); } function renderReplyAttachments() { $("#replyAttachments").innerHTML = state.replyFiles.map((file, index) => ` ${escapeHtml(file.name)} `).join(""); } function addReplyFiles(files) { state.replyFiles = mergeImageFiles(state.replyFiles, files); renderReplyAttachments(); } function clearReplyComposer(form = $("#replyForm")) { const textarea = form?.querySelector("textarea[name='message']"); if (!textarea) return; textarea.value = ""; textarea.defaultValue = ""; textarea.textContent = ""; textarea.dispatchEvent(new Event("input", { bubbles: true })); } function mergeImageFiles(currentFiles, incomingFiles) { const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const nextFiles = [...currentFiles]; for (const file of incomingFiles) { if (!allowedTypes.has(file.type) || file.size <= 0 || file.size > 3_000_000) continue; const exists = nextFiles.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified); if (!exists) nextFiles.push(file); if (nextFiles.length === 3) break; } return nextFiles; } function fileToPayload(file) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, dataBase64: reader.result }); reader.readAsDataURL(file); }); } function countryFromLanguage(language) { const match = String(language || "").match(/-([A-Za-z]{2})\b/); return match ? match[1].toUpperCase() : null; } function countryFromTimezone(timezone) { const zones = { "Europe/Prague": "CZ", "Europe/Bratislava": "SK", "Europe/Warsaw": "PL", "Europe/Berlin": "DE", "Europe/Vienna": "AT", "Europe/Budapest": "HU" }; return zones[String(timezone || "")] || null; } function shortUrl(value) { if (!value) return ""; try { const url = new URL(value); const path = `${url.pathname}${url.search}`.replace(/\/$/, ""); return `${url.hostname}${path || "/"}`; } catch { return String(value); } } function pageLabel(item) { return item.title || shortUrl(item.url) || item.url || "stranka"; } function formatVisitedAt(value) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" }); } function formatTime(value) { const text = String(value || ""); const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text; const date = new Date(normalized); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" }); } function deviceLabel(device = {}, language, timezone) { return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | "); } function openPhotoPreview(url, name) { let modal = $("#photoModal"); if (!modal) { modal = document.createElement("div"); modal.id = "photoModal"; modal.className = "photo-modal hidden"; modal.innerHTML = `
    `; document.body.append(modal); modal.addEventListener("click", (event) => { if (event.target.closest(".photo-close") || event.target.classList.contains("photo-backdrop")) { modal.classList.add("hidden"); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") modal.classList.add("hidden"); }); } modal.querySelector("img").src = url; modal.querySelector("img").alt = name || "Fotka"; modal.querySelector("figcaption").textContent = name || ""; modal.classList.remove("hidden"); } async function saveSite(patch) { const data = await api("/api/admin/site", { method: "PATCH", body: patch }); state.site = data.site; state.snippet = data.snippet; renderSite(); } function notify() { startAttention(); playNotifySound(); } function playNotifySound() { const AudioCtor = window.AudioContext || window.webkitAudioContext; if (!AudioCtor) return; const audio = new AudioCtor(); const gain = audio.createGain(); gain.gain.value = 0.055; gain.connect(audio.destination); [0, 0.14].forEach((offset, index) => { const osc = audio.createOscillator(); osc.type = "sine"; osc.frequency.value = index ? 1040 : 820; osc.connect(gain); osc.start(audio.currentTime + offset); osc.stop(audio.currentTime + offset + 0.11); }); setTimeout(() => audio.close().catch(() => {}), 520); } function startAttention() { if (state.attentionTimer) return; const favicon = document.querySelector("link[rel='icon']"); const alertIcon = `data:image/svg+xml,${encodeURIComponent('')}`; state.attentionTimer = setInterval(() => { state.attentionOn = !state.attentionOn; document.title = state.attentionOn ? "* Nova zprava - MaalFlows" : state.originalTitle; if (favicon) favicon.href = state.attentionOn ? alertIcon : state.originalFavicon; }, 760); } function stopAttention() { if (state.attentionTimer) clearInterval(state.attentionTimer); state.attentionTimer = null; state.attentionOn = false; document.title = state.originalTitle; const favicon = document.querySelector("link[rel='icon']"); if (favicon) favicon.href = state.originalFavicon; } function isVisitorMessageEvent(data) { if (data.type !== "message:new") return false; const messages = data.payload?.messages || []; return messages[messages.length - 1]?.senderType === "visitor"; } function flashTyping() { $("#typingNotice").classList.remove("hidden"); clearTimeout(flashTyping.timer); flashTyping.timer = setTimeout(() => $("#typingNotice").classList.add("hidden"), 1800); } async function api(url, options = {}) { const response = await fetch(url, { method: options.method || "GET", headers: { "Content-Type": "application/json" }, body: options.body ? JSON.stringify(options.body) : undefined }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } function debounce(fn, delay) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); } function normalizeHexColor(value) { const match = String(value || "").trim().match(/^#?([0-9a-fA-F]{6})$/); return match ? `#${match[1].toLowerCase()}` : ""; } function flag(countryCode) { if (!countryCode || countryCode.length !== 2) return ""; return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt())); }