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;
}
.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
View File
@@ -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
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) {