509 lines
18 KiB
JavaScript
509 lines
18 KiB
JavaScript
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"));
|
||
await saveSite({ settings, isOnline: state.site.isOnline });
|
||
});
|
||
|
||
$("#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.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;
|
||
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">
|
||
${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>
|
||
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
|
||
<p>${visitorCountry(conversation)} ${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 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);
|
||
return countryCode ? `${flag(countryCode)} ${escapeHtml(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 `
|
||
<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 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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
})[char]);
|
||
}
|
||
|
||
function flag(countryCode) {
|
||
if (!countryCode || countryCode.length !== 2) return "";
|
||
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
|
||
}
|