Add admin image replies and attachment removal

This commit is contained in:
rajchales-monito
2026-07-23 23:01:53 +02:00
parent 7b6d855fec
commit bb2c267252
4 changed files with 228 additions and 21 deletions
+115 -8
View File
@@ -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 = `
<div>
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
<p>${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}</p>
<p>${visitorCountry(conversation)} ${escapeHtml(conversation.currentUrl || "")}</p>
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
</div>
`;
@@ -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 `<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>
<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) {