Add admin image replies and attachment removal
This commit is contained in:
+80
-8
@@ -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;
|
||||
|
||||
+9
-2
@@ -57,8 +57,15 @@
|
||||
<div id="messages" class="messages"></div>
|
||||
<div id="typingNotice" class="typing hidden">Zakaznik pise...</div>
|
||||
<form id="replyForm" class="reply hidden">
|
||||
<textarea name="message" rows="3" required placeholder="Napsat odpoved..."></textarea>
|
||||
<button type="submit">Odeslat</button>
|
||||
<div class="reply-composer">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
+115
-8
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user