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",
soundMuted: localStorage.getItem("mf_admin_sound_muted") === "1",
soundVolume: Number(localStorage.getItem("mf_admin_sound_volume") || 70),
soundStyle: localStorage.getItem("mf_admin_sound_style") || "bright",
soundRepeat: localStorage.getItem("mf_admin_sound_repeat") === "1",
replyFiles: [],
attentionTimer: null,
soundRepeatTimer: null,
attentionOn: false,
originalTitle: document.title,
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
aiModels: []
};
const $ = (selector) => document.querySelector(selector);
boot();
renderAdminSoundControls();
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.toggle("hidden"));
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
$("#adminSoundToggle").addEventListener("click", () => {
setAdminSoundMuted(!state.soundMuted);
});
$("#adminSoundVolume").addEventListener("input", (event) => {
setAdminSoundVolume(Number(event.target.value));
});
$("#adminSoundToggleSettings").addEventListener("change", (event) => {
setAdminSoundMuted(!event.target.checked);
});
$("#adminSoundVolumeSettings").addEventListener("input", (event) => {
setAdminSoundVolume(Number(event.target.value));
});
$("#adminSoundStyle").addEventListener("change", (event) => {
state.soundStyle = event.target.value;
localStorage.setItem("mf_admin_sound_style", state.soundStyle);
renderAdminSoundControls();
});
$("#adminSoundRepeat").addEventListener("change", (event) => {
state.soundRepeat = event.target.checked;
localStorage.setItem("mf_admin_sound_repeat", state.soundRepeat ? "1" : "0");
if (!state.soundRepeat) stopRepeatingSound();
renderAdminSoundControls();
});
$("#adminSoundTest").addEventListener("click", () => {
playNotifySound({ ignoreMuted: true });
});
$("#translationEnabled").addEventListener("change", saveTranslationSettings);
$("#operatorLanguage").addEventListener("change", saveTranslationSettings);
$("#translationModel").addEventListener("change", saveTranslationSettings);
$("#openaiApiKey").addEventListener("change", saveTranslationSettings);
$("#refreshAiModels").addEventListener("click", loadAiModels);
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.settingsTab;
document.querySelectorAll("[data-settings-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-settings-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === tab));
});
});
$("#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", syncAttentionWithUnread);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) syncAttentionWithUnread();
});
$("#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 loadAiModels().catch(() => {});
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();
syncAttentionWithUnread();
}
async function loadVisitors() {
const data = await api("/api/admin/visitors");
state.visitors = data.visitors;
state.visitorsLoaded = true;
syncConversationVisitors();
renderConversations();
renderVisitors();
}
async function openConversation(id) {
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();
syncAttentionWithUnread();
}
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;
}
const visitorMessage = isVisitorMessageEvent(data);
const eventConversationId = eventConversationIdFrom(data);
const shouldNotify = data.type === "conversation:new" || (visitorMessage && eventConversationId !== state.activeId);
await loadConversations();
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
clearActiveConversation();
return;
}
if (state.activeId) await openConversation(state.activeId).catch(() => clearActiveConversation());
if (shouldNotify) notify();
else syncAttentionWithUnread();
});
}
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;
$("#translationEnabled").checked = Boolean(s.translations?.enabled);
$("#operatorLanguage").value = s.translations?.operatorLanguage || "cs";
$("#openaiApiKey").value = "";
$("#openaiApiKeyStatus").textContent = s.translations?.hasOpenaiApiKey
? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})`
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.";
renderModelOptions(s.translations?.model || "gpt-5-mini");
}
async function saveTranslationSettings() {
if (!state.site) return;
const settings = structuredClone(state.site.settings);
settings.translations = {
...(settings.translations || {}),
enabled: $("#translationEnabled").checked,
operatorLanguage: $("#operatorLanguage").value,
model: $("#translationModel").value.trim() || "gpt-5-mini"
};
const apiKey = $("#openaiApiKey").value.trim();
if (apiKey) settings.translations.openaiApiKey = apiKey;
await saveSite({ settings, isOnline: state.site.isOnline });
$("#openaiApiKey").value = "";
await loadAiModels().catch(() => {});
}
async function loadAiModels() {
const current = $("#translationModel").value || state.site?.settings?.translations?.model || "gpt-5-mini";
const data = await api("/api/admin/ai/models").catch((error) => error?.models ? error : null);
state.aiModels = data?.models || fallbackAiModels();
renderModelOptions(current);
}
function renderModelOptions(current) {
const select = $("#translationModel");
const models = [...new Set([current, ...state.aiModels, ...fallbackAiModels()].filter(Boolean))];
select.innerHTML = models.map((model) => ``).join("");
select.value = models.includes(current) ? current : models[0];
}
function fallbackAiModels() {
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
}
function renderConversations() {
const conversations = filteredConversations();
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
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) => `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 = `${escapeHtml(message.body)}
` : ""; if (!message.translatedBody) return original; const label = message.senderType === "visitor" ? `Preklad do ${escapeHtml(message.translatedLanguage || "")}` : `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`; return ` ${original}${escapeHtml(message.translatedBody)}