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
+80 -8
View File
@@ -204,9 +204,9 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
margin-top: 8px; margin-top: 8px;
} }
.photo-preview { .photo-preview {
width: 116px; width: min(240px, 100%);
display: grid; display: grid;
gap: 5px; gap: 0;
padding: 0; padding: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
@@ -214,22 +214,47 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
color: var(--ink); color: var(--ink);
overflow: hidden; overflow: hidden;
text-align: left; text-align: left;
margin: 0;
} }
.photo-preview img { .photo-open {
width: 100%; 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; object-fit: cover;
display: block; display: block;
background: #edf3ef; background: #edf3ef;
} }
.photo-preview span { .photo-preview figcaption {
padding: 0 7px 7px; display: grid;
grid-template-columns: minmax(0, 1fr) 30px;
gap: 6px;
align-items: center;
padding: 6px 7px;
}
.photo-preview figcaption span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--muted); color: var(--muted);
font-size: 12px; 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 { .photo-modal {
position: fixed; position: fixed;
inset: 0; 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; } .typing { padding: 0 20px 10px; color: var(--brand); font-size: 13px; }
.reply { .reply {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; gap: 8px;
gap: 10px;
padding: 14px; padding: 14px;
background: white; background: white;
border-top: 1px solid var(--line); 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 { .settings {
position: fixed; position: fixed;
+9 -2
View File
@@ -57,8 +57,15 @@
<div id="messages" class="messages"></div> <div id="messages" class="messages"></div>
<div id="typingNotice" class="typing hidden">Zakaznik pise...</div> <div id="typingNotice" class="typing hidden">Zakaznik pise...</div>
<form id="replyForm" class="reply hidden"> <form id="replyForm" class="reply hidden">
<textarea name="message" rows="3" required placeholder="Napsat odpoved..."></textarea> <div class="reply-composer">
<button type="submit">Odeslat</button> <textarea name="message" rows="3" placeholder="Napsat odpoved..."></textarea>
<button type="submit" title="Odeslat">Odeslat</button>
</div>
<label class="admin-dropzone" tabindex="0">
<input name="attachments" type="file" accept=".jpg,.jpeg,.png,.webp,.gif,.avif,image/jpeg,image/png,image/webp,image/gif,image/avif" multiple>
<span>Pridat fotky nebo pretahnout</span>
</label>
<div id="replyAttachments" class="reply-attachments"></div>
</form> </form>
</section> </section>
+115 -8
View File
@@ -5,7 +5,8 @@ const state = {
activeId: null, activeId: null,
active: null, active: null,
events: null, events: null,
statusFilter: "active" statusFilter: "active",
replyFiles: []
}; };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
@@ -66,19 +67,71 @@ $("#replyForm").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (!state.activeId) return; if (!state.activeId) return;
const form = new FormData(event.currentTarget); 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`, { await api(`/api/admin/conversations/${state.activeId}/messages`, {
method: "POST", method: "POST",
body: { message: form.get("message") } body: payload
}); });
event.currentTarget.reset(); event.currentTarget.reset();
state.replyFiles = [];
renderReplyAttachments();
await openConversation(state.activeId); 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(() => { $("#replyForm textarea").addEventListener("input", debounce(() => {
if (!state.activeId) return; if (!state.activeId) return;
api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {}); api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {});
}, 600)); }, 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", () => { $("#conversationFilter").addEventListener("change", () => {
state.statusFilter = $("#conversationFilter").value; state.statusFilter = $("#conversationFilter").value;
renderConversations(); renderConversations();
@@ -106,6 +159,11 @@ $("#conversationList").addEventListener("change", async (event) => {
}); });
$("#messages").addEventListener("click", (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]"); const preview = event.target.closest("[data-photo-url]");
if (!preview) return; if (!preview) return;
openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName); openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName);
@@ -216,7 +274,7 @@ function renderActive() {
$("#conversationHeader").innerHTML = ` $("#conversationHeader").innerHTML = `
<div> <div>
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2> <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> <p>${escapeHtml(conversation.device.userAgent || "")}</p>
</div> </div>
`; `;
@@ -247,7 +305,8 @@ function statusOptions(currentStatus) {
} }
function visitorCountry(item) { 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) { 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 `<a href="${escapeHtml(attachment.url)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`;
} }
return ` return `
<button class="photo-preview" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}"> <figure class="photo-preview">
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}"> <button class="photo-open" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
<span>${escapeHtml(attachment.name)}</span> <img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
</button> </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>`; }).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) { function openPhotoPreview(url, name) {
let modal = $("#photoModal"); let modal = $("#photoModal");
if (!modal) { if (!modal) {
+24 -3
View File
@@ -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/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/site" && req.method === "PATCH") return updateSite(req, res);
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(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 === "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\/[^/]+$/) && 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\/[^/]+\/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) { async function createAdminMessage(req, res, url) {
const body = await readJson(req, 2_000_000); const body = await readJson(req, 8_000_000);
if (!body.message || !String(body.message).trim()) return json(res, 400, { error: "message_required" }); 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)); const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" }); 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); bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
const updated = findConversation(conversation.public_id); const updated = findConversation(conversation.public_id);
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) }); publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
return json(res, 201, 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) { async function visitorTyping(req, res, url) {
const conversation = findConversation(publicIdFrom(url)); const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" }); if (!conversation) return json(res, 404, { error: "conversation_not_found" });