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 = `${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}
+${visitorCountry(conversation)} ${escapeHtml(conversation.currentUrl || "")}
${escapeHtml(conversation.device.userAgent || "")}