Files
MaalFlows/public/admin.js
T

401 lines
14 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"
};
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"));
await saveSite({ settings, isOnline: state.site.isOnline });
});
$("#replyForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeId) return;
const form = new FormData(event.currentTarget);
await api(`/api/admin/conversations/${state.activeId}/messages`, {
method: "POST",
body: { message: form.get("message") }
});
event.currentTarget.reset();
await openConversation(state.activeId);
});
$("#replyForm textarea").addEventListener("input", debounce(() => {
if (!state.activeId) return;
api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {});
}, 600));
$("#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("change", async (event) => {
const statusSelect = event.target.closest("[data-status-for]");
if (!statusSelect) return;
await updateConversationStatus(statusSelect.dataset.statusFor, statusSelect.value);
});
$("#messages").addEventListener("click", (event) => {
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;
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;
}
function renderConversations() {
const conversations = filteredConversations();
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}">
<button class="conversation-open" type="button" data-open="${item.id}">
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong>${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}</span>
<span class="preview">${escapeHtml(item.lastBody || "")}</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">
<select class="status-select ${item.status}" data-status-for="${item.id}" aria-label="Zmenit stav chatu">
${statusOptions(item.status)}
</select>
<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>
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
<p>${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}</p>
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
</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 statusOptions(currentStatus) {
return [
["new", "nove"],
["open", "otevrene"],
["resolved", "vyresene"],
["spam", "spam"]
].map(([value, label]) => `<option value="${value}" ${value === currentStatus ? "selected" : ""}>${label}</option>`).join("");
}
function visitorCountry(item) {
return item.countryCode ? `${flag(item.countryCode)} ${escapeHtml(item.countryCode)}` : "zeme ?";
}
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 `
<button class="photo-preview" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
<span>${escapeHtml(attachment.name)}</span>
</button>
`;
}).join("")}</div>`;
}
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()));
}