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"));
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 = 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.notifications = settings.notifications || {};
settings.notifications.pulseOnAdminReply = form.get("pulseOnAdminReply") === "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 = s.colors.brand || "#0f8f6f";
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.pulseOnAdminReply.checked = s.notifications?.pulseOnAdminReply !== false;
}
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 = `