From 8c71dbf1bfc66e0f1d6915b30dd8905cfecf1763 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 23 Jul 2026 22:35:36 +0200 Subject: [PATCH] Improve admin chat controls --- public/admin.css | 80 ++++++++++++++++++++++++++++-- public/admin.html | 18 ++++--- public/admin.js | 122 +++++++++++++++++++++++++++++++++++++--------- server.js | 27 +++++++++- 4 files changed, 211 insertions(+), 36 deletions(-) diff --git a/public/admin.css b/public/admin.css index bddfece..fc1ecb4 100644 --- a/public/admin.css +++ b/public/admin.css @@ -55,7 +55,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } border-right: 1px solid var(--line); background: var(--panel); display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto minmax(0, 1fr); } .brand, .sitebar { display: flex; @@ -69,22 +69,40 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } .brand button, .sitebar button { background: #edf3ef; color: var(--ink); } .switch { display: flex; align-items: center; gap: 8px; } .switch input { width: auto; } +.list-tools { + padding: 10px 14px; + border-bottom: 1px solid var(--line); + background: #fbfdfb; +} +.list-tools label { color: var(--muted); } .conversation-list { overflow: auto; } .conversation-item { - width: 100%; - text-align: left; border: 0; border-bottom: 1px solid var(--line); background: white; color: var(--ink); border-radius: 0; - padding: 12px 14px; + padding: 10px; display: grid; - gap: 5px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; } .conversation-item.active { background: #eaf6f1; } .conversation-item.fresh { animation: pulse 1.2s ease-in-out 4; } +.conversation-open { + min-width: 0; + text-align: left; + display: grid; + gap: 5px; + border: 0; + background: transparent; + color: var(--ink); + border-radius: 6px; + padding: 2px; +} +.conversation-open:hover { filter: none; background: #f3f7f4; } .row { display: flex; justify-content: space-between; gap: 10px; align-items: center; } .status { font-size: 12px; @@ -93,7 +111,59 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } border-radius: 999px; padding: 2px 8px; } +.photo-count { + flex: 0 0 auto; + color: var(--brand); + font-size: 12px; +} .snippet, .preview, .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.list-photo { + grid-column: 1 / -1; + width: 100%; + height: 74px; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #edf3ef; +} +.list-photo img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} +.conversation-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) 34px; + gap: 8px; +} +.status-select { + height: 34px; + padding: 5px 8px; + font-size: 12px; +} +.status-select.new { border-color: #2f9e44; background: #effaf2; } +.status-select.open { border-color: #2f80ed; background: #eef5ff; } +.status-select.resolved { border-color: #87908a; background: #f3f5f3; } +.status-select.spam { border-color: #d92d20; background: #fff1f0; } +.delete-chat { + width: 34px; + height: 34px; + padding: 0; + display: grid; + place-items: center; + background: #fff1f0; + color: #b42318; +} +.delete-chat svg { width: 18px; height: 18px; fill: currentColor; } +.empty-list { + margin: 0; + padding: 18px 14px; + color: var(--muted); + font-size: 13px; +} .conversation { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-width: 0; } .conversation-header { diff --git a/public/admin.html b/public/admin.html index 569aac6..b9f921f 100644 --- a/public/admin.html +++ b/public/admin.html @@ -32,6 +32,18 @@ +
+ +
@@ -41,12 +53,6 @@

Vyber konverzaci

Nova konverzace se tady zvyrazni a pusti zvuk.

-
diff --git a/public/admin.js b/public/admin.js index 3f29cf9..6ad5b6e 100644 --- a/public/admin.js +++ b/public/admin.js @@ -4,7 +4,8 @@ const state = { conversations: [], activeId: null, active: null, - events: null + events: null, + statusFilter: "active" }; const $ = (selector) => document.querySelector(selector); @@ -78,13 +79,30 @@ $("#replyForm textarea").addEventListener("input", debounce(() => { 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(); +$("#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) => { @@ -140,7 +158,11 @@ function connectEvents() { return; } await loadConversations(); - if (state.activeId) await openConversation(state.activeId); + 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(); }); } @@ -164,21 +186,29 @@ function renderSite() { } function renderConversations() { - $("#conversationList").innerHTML = state.conversations.map((item) => ` - - `).join(""); - for (const button of document.querySelectorAll(".conversation-item")) { - button.addEventListener("click", () => openConversation(button.dataset.id)); - } + const conversations = filteredConversations(); + $("#conversationList").innerHTML = conversations.length ? conversations.map((item) => ` +
+ + ${item.firstImageUrl ? `` : ""} +
+ + +
+
+ `).join("") : `

Zadny chat pro vybrany filtr.

`; } function renderActive() { const { conversation, messages } = state.active; - const statusSelect = $("#statusSelect"); $("#conversationHeader").innerHTML = `

${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}

@@ -186,9 +216,6 @@ function renderActive() {

${escapeHtml(conversation.device.userAgent || "")}

`; - $("#conversationHeader").append(statusSelect); - statusSelect.classList.remove("hidden"); - statusSelect.value = conversation.status; $("#replyForm").classList.remove("hidden"); $("#messages").innerHTML = messages.map((message) => `
@@ -200,6 +227,55 @@ function renderActive() { $("#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]) => ``).join(""); +} + +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 = ` +
+

Vyber konverzaci

+

Nova konverzace se tady zvyrazni a pusti zvuk.

+
+ `; + $("#messages").innerHTML = ""; + $("#replyForm").classList.add("hidden"); +} + function renderAttachments(attachments) { if (!attachments.length) return ""; return `
${attachments.map((attachment) => { diff --git a/server.js b/server.js index e51569c..bb30dc8 100644 --- a/server.js +++ b/server.js @@ -77,6 +77,7 @@ async function adminApi(req, res, url) { if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res); if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(res, url); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url); @@ -256,7 +257,9 @@ function adminConversations(res) { const rows = db.prepare(` SELECT c.*, s.site_key, (SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body, - (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count + (SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count, + (SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count, + (SELECT public_url FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%' ORDER BY id DESC LIMIT 1) AS first_image_url FROM conversations c JOIN sites s ON s.id = c.site_id ORDER BY datetime(c.last_message_at) DESC, c.id DESC @@ -297,6 +300,24 @@ async function updateConversationStatus(req, res, url) { return json(res, 200, { conversation: formatConversationSummary(updated) }); } +function deleteConversation(res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + const attachments = db.prepare("SELECT storage_path FROM attachments WHERE conversation_id = ?").all(conversation.id); + db.transaction(() => { + db.prepare("DELETE FROM attachments WHERE conversation_id = ?").run(conversation.id); + db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversation.id); + db.prepare("DELETE FROM conversations WHERE id = ?").run(conversation.id); + })(); + for (const attachment of attachments) { + if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) { + fs.rmSync(attachment.storage_path, { force: true }); + } + } + publish("admin", { type: "conversation:delete", conversationId: conversation.public_id }); + return json(res, 200, { ok: true }); +} + function widgetConfig(res, url) { const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus"; const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey); @@ -501,6 +522,8 @@ function formatConversationSummary(row) { vpnRisk: Boolean(row.vpn_risk), lastBody: row.last_body, messageCount: row.message_count, + imageCount: row.image_count || 0, + firstImageUrl: row.first_image_url, lastMessageAt: row.last_message_at, createdAt: row.created_at }; @@ -582,7 +605,7 @@ function json(res, status, payload) { function setCommonHeaders(res) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); - res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS"); } function endCors(res) {