Files
MaalFlows/public/admin.js
T

593 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
site: null,
snippet: "",
conversations: [],
activeId: null,
active: null,
events: null,
statusFilter: "active",
replyFiles: []
};
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"));
$("#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 form = new FormData(event.currentTarget);
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;
await api(`/api/admin/conversations/${state.activeId}/messages`, {
method: "POST",
body: payload
});
event.currentTarget.querySelector("textarea[name='message']").value = "";
event.currentTarget.reset();
state.replyFiles = [];
renderReplyAttachments();
await openConversation(state.activeId);
});
$("#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) => {
state.replyFiles = mergeImageFiles(state.replyFiles, [...event.target.files]);
event.target.value = "";
renderReplyAttachments();
});
$(".admin-dropzone").addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
$("#replyForm input[name='attachments']").click();
}
});
for (const eventName of ["dragenter", "dragover"]) {
$(".admin-dropzone").addEventListener(eventName, (event) => {
event.preventDefault();
$(".admin-dropzone").classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
$(".admin-dropzone").addEventListener(eventName, (event) => {
event.preventDefault();
$(".admin-dropzone").classList.remove("dragging");
});
}
$(".admin-dropzone").addEventListener("drop", (event) => {
state.replyFiles = mergeImageFiles(state.replyFiles, [...event.dataTransfer.files]);
renderReplyAttachments();
});
$("#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();
});
$("#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);
});
async function showApp() {
$("#loginView").classList.add("hidden");
$("#appView").classList.remove("hidden");
await loadBootstrap();
await loadConversations();
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;
renderConversations();
}
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 };
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;
}
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" || data.type === "message:new") 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) => `
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.hasUnread ? "unread fresh" : ""}">
<button class="conversation-open" type="button" data-open="${item.id}">
<span class="row visitor-row">
<span class="visitor-name-wrap">
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
<strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong>
</span>
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
</span>
<span class="visitor-facts">
<span title="Zeme">${visitorCountry(item)}</span>
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
<span title="Navstivene stranky">${Number(item.pageCount || 0)} str.</span>
<span title="Pocet chatu">${Number(item.visitorConversationCount || 1)}x chat</span>
</span>
</button>
<div class="conversation-actions">
${statusButtons(item)}
<button class="delete-chat" type="button" data-delete="${item.id}" aria-label="Smazat chat" title="Smazat chat">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
</button>
</div>
</article>
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
}
function renderActive() {
const { conversation, messages } = state.active;
$("#conversationHeader").innerHTML = `
<div class="visitor-head">
<div class="visitor-title-row">
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
<span class="country-pill">${visitorCountry(conversation)}</span>
</div>
<div class="visitor-quick">
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
</div>
${visitorDetails(conversation)}
</div>
`;
$("#replyForm").classList.remove("hidden");
$("#messages").innerHTML = messages.map((message) => `
<article class="message ${message.senderType}">
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
<p>${escapeHtml(message.body)}</p>
${renderAttachments(message.attachments)}
</article>
`).join("");
$("#messages").scrollTop = $("#messages").scrollHeight;
}
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]) => `
<button class="status-button ${value} ${item.status === value ? "active" : ""}" type="button" data-status-button="${item.id}" data-status="${value}">
${label}
</button>
`).join("");
}
function visitorCountry(item) {
const countryCode = item.countryCode || countryFromLanguage(item.language) || countryFromTimezone(item.timezone);
return countryCode ? `${flag(countryCode)} ${escapeHtml(countryCode)}` : "zeme ?";
}
function visitorDetails(conversation) {
const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : [];
const historyItems = history.length ? history.slice().reverse().map((item) => `
<li>
<a href="${escapeHtml(item.url || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageLabel(item))}</a>
<small>${escapeHtml(formatVisitedAt(item.at))}</small>
</li>
`).join("") : `<li><span>Zatim bez historie.</span></li>`;
return `
<details class="visitor-details">
<summary title="Detail navstevnika">
<span aria-hidden="true">i</span>
<strong>Detail navstevnika</strong>
</summary>
<div class="visitor-detail-grid">
<span>Aktualne</span>
<a href="${escapeHtml(conversation.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.currentUrl) || "-")}</a>
<span>Prisiel z</span>
${conversation.referrer ? `<a href="${escapeHtml(conversation.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.referrer))}</a>` : `<em>primy vstup / nezname</em>`}
<span>Zarizeni</span>
<em>${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}</em>
</div>
<ol class="history-list">${historyItems}</ol>
</details>
`;
}
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 = `
<div>
<h2>Vyber konverzaci</h2>
<p>Nova konverzace se tady zvyrazni a pusti zvuk.</p>
</div>
`;
$("#messages").innerHTML = "";
$("#replyForm").classList.add("hidden");
}
function renderAttachments(attachments) {
if (!attachments.length) return "";
return `<div class="attachments">${attachments.map((attachment) => {
if (!attachment.type?.startsWith("image/")) {
return `<a href="${escapeHtml(attachment.url)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`;
}
return `
<figure class="photo-preview">
<button class="photo-open" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
</button>
<figcaption>
<span>${escapeHtml(attachment.name)}</span>
<button class="photo-delete" type="button" data-delete-attachment="${attachment.id}" aria-label="Smazat fotku" title="Smazat fotku">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
</button>
</figcaption>
</figure>
`;
}).join("")}</div>`;
}
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) => `
<span class="reply-chip">
<span>${escapeHtml(file.name)}</span>
<button type="button" data-remove-reply="${index}" aria-label="Odebrat fotku">×</button>
</span>
`).join("");
}
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 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 = `
<button class="photo-backdrop" type="button" aria-label="Zavrit nahled"></button>
<figure>
<button class="photo-close" type="button" aria-label="Zavrit nahled">×</button>
<img alt="">
<figcaption></figcaption>
</figure>
`;
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() {
document.title = "* Nova zprava - MaalFlows";
const audio = new AudioContext();
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.04;
osc.connect(gain);
gain.connect(audio.destination);
osc.start();
setTimeout(() => {
osc.stop();
audio.close();
}, 160);
}
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
})[char]);
}
function flag(countryCode) {
if (!countryCode || countryCode.length !== 2) return "";
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
}