Files
MaalFlows/public/admin.js
T

309 lines
10 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
};
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));
$("#statusSelect").addEventListener("change", async () => {
if (!state.activeId) return;
await api(`/api/admin/conversations/${state.activeId}/status`, {
method: "PATCH",
body: { status: $("#statusSelect").value }
});
await loadConversations();
});
$("#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 (state.activeId) await openConversation(state.activeId);
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() {
$("#conversationList").innerHTML = state.conversations.map((item) => `
<button class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}" data-id="${item.id}">
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong><span class="status">${item.status}</span></span>
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
<span class="meta">${flag(item.countryCode)} ${escapeHtml(item.language || "")} ${escapeHtml(item.currentUrl || "")}</span>
</button>
`).join("");
for (const button of document.querySelectorAll(".conversation-item")) {
button.addEventListener("click", () => openConversation(button.dataset.id));
}
}
function renderActive() {
const { conversation, messages } = state.active;
const statusSelect = $("#statusSelect");
$("#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>
`;
$("#conversationHeader").append(statusSelect);
statusSelect.classList.remove("hidden");
statusSelect.value = conversation.status;
$("#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 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()));
}