From bb2c267252f08056ad60d0219f690bb953a1ab20 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 23 Jul 2026 23:01:53 +0200 Subject: [PATCH] Add admin image replies and attachment removal --- public/admin.css | 88 ++++++++++++++++++++++++++++++--- public/admin.html | 11 ++++- public/admin.js | 123 +++++++++++++++++++++++++++++++++++++++++++--- server.js | 27 ++++++++-- 4 files changed, 228 insertions(+), 21 deletions(-) diff --git a/public/admin.css b/public/admin.css index 92e1d8f..c34d103 100644 --- a/public/admin.css +++ b/public/admin.css @@ -204,9 +204,9 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } margin-top: 8px; } .photo-preview { - width: 116px; + width: min(240px, 100%); display: grid; - gap: 5px; + gap: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; @@ -214,22 +214,47 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } color: var(--ink); overflow: hidden; text-align: left; + margin: 0; } -.photo-preview img { +.photo-open { width: 100%; - aspect-ratio: 4 / 3; + padding: 0; + border: 0; + border-radius: 0; + background: #edf3ef; + color: var(--ink); +} +.photo-open img { + width: 100%; + aspect-ratio: 16 / 10; object-fit: cover; display: block; background: #edf3ef; } -.photo-preview span { - padding: 0 7px 7px; +.photo-preview figcaption { + display: grid; + grid-template-columns: minmax(0, 1fr) 30px; + gap: 6px; + align-items: center; + padding: 6px 7px; +} +.photo-preview figcaption span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 12px; } +.photo-delete { + width: 30px; + height: 30px; + padding: 0; + display: grid; + place-items: center; + background: #fff1f0; + color: #b42318; +} +.photo-delete svg { width: 16px; height: 16px; fill: currentColor; } .photo-modal { position: fixed; inset: 0; @@ -279,12 +304,59 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } .typing { padding: 0 20px 10px; color: var(--brand); font-size: 13px; } .reply { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; + gap: 8px; padding: 14px; background: white; border-top: 1px solid var(--line); } +.reply-composer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} +.admin-dropzone { + min-height: 38px; + border: 1px dashed var(--line); + border-radius: 8px; + padding: 8px 10px; + display: flex; + align-items: center; + color: var(--muted); + background: #f8fbf9; + cursor: pointer; +} +.admin-dropzone.dragging { border-color: var(--brand); background: #edf8f3; color: var(--ink); } +.admin-dropzone input { display: none; } +.reply-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.reply-chip { + max-width: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + background: #f8fbf9; + padding: 5px 5px 5px 9px; + font-size: 12px; +} +.reply-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 240px; +} +.reply-chip button { + width: 22px; + height: 22px; + border-radius: 50%; + padding: 0; + background: #e8eee9; + color: var(--ink); +} .settings { position: fixed; diff --git a/public/admin.html b/public/admin.html index b9f921f..6b7d2e5 100644 --- a/public/admin.html +++ b/public/admin.html @@ -57,8 +57,15 @@
diff --git a/public/admin.js b/public/admin.js index 5651a47..008968f 100644 --- a/public/admin.js +++ b/public/admin.js @@ -5,7 +5,8 @@ const state = { activeId: null, active: null, events: null, - statusFilter: "active" + statusFilter: "active", + replyFiles: [] }; const $ = (selector) => document.querySelector(selector); @@ -66,19 +67,71 @@ $("#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: { message: form.get("message") } + 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(); @@ -106,6 +159,11 @@ $("#conversationList").addEventListener("change", async (event) => { }); $("#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); @@ -216,7 +274,7 @@ function renderActive() { $("#conversationHeader").innerHTML = `

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

-

${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}

+

${visitorCountry(conversation)} ${escapeHtml(conversation.currentUrl || "")}

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

`; @@ -247,7 +305,8 @@ function statusOptions(currentStatus) { } function visitorCountry(item) { - return item.countryCode ? `${flag(item.countryCode)} ${escapeHtml(item.countryCode)}` : "zeme ?"; + const countryCode = item.countryCode || countryFromLanguage(item.language); + return countryCode ? `${flag(countryCode)} ${escapeHtml(countryCode)}` : "zeme ?"; } function formatDuration(seconds) { @@ -299,14 +358,62 @@ function renderAttachments(attachments) { return `${escapeHtml(attachment.name)}`; } return ` - +
+ +
+ ${escapeHtml(attachment.name)} + +
+
`; }).join("")}`; } +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) => ` + + ${escapeHtml(file.name)} + + + `).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) { diff --git a/server.js b/server.js index 3948e5a..459a5cf 100644 --- a/server.js +++ b/server.js @@ -77,6 +77,7 @@ async function adminApi(req, res, url) { if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin"); 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\/attachments\/\d+$/) && req.method === "DELETE") return deleteAttachment(res, url); 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); @@ -398,17 +399,37 @@ async function createVisitorMessage(req, res, url) { } async function createAdminMessage(req, res, url) { - const body = await readJson(req, 2_000_000); - if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); + const body = await readJson(req, 8_000_000); + if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" }); + if (!attachmentsAreAllowed(body.attachments || [])) return json(res, 400, { error: "only_images_allowed" }); const conversation = findConversation(publicIdFrom(url)); if (!conversation) return json(res, 404, { error: "conversation_not_found" }); - insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message).trim()); + const messageId = insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message || "").trim()); + saveAttachments(conversation.id, messageId, body.attachments || []); bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status); const updated = findConversation(conversation.public_id); publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) }); return json(res, 201, conversationDetails(updated)); } +function deleteAttachment(res, url) { + const attachmentId = Number(url.pathname.split("/").pop()); + const attachment = db.prepare(` + SELECT a.*, c.public_id + FROM attachments a + JOIN conversations c ON c.id = a.conversation_id + WHERE a.id = ? + `).get(attachmentId); + if (!attachment) return json(res, 404, { error: "attachment_not_found" }); + db.prepare("DELETE FROM attachments WHERE id = ?").run(attachmentId); + if (attachment.storage_path && path.resolve(attachment.storage_path).startsWith(path.resolve(UPLOAD_DIR))) { + fs.rmSync(attachment.storage_path, { force: true }); + } + const conversation = findConversation(attachment.public_id); + if (conversation) publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) }); + return json(res, 200, { ok: true }); +} + async function visitorTyping(req, res, url) { const conversation = findConversation(publicIdFrom(url)); if (!conversation) return json(res, 404, { error: "conversation_not_found" });