Upgrade allow adding image in ticket details page comment section

This commit is contained in:
2026-03-30 11:14:35 -04:00
parent 2beef382b7
commit bf29ccb287
13 changed files with 419 additions and 26 deletions
+118 -18
View File
@@ -105,11 +105,21 @@
<div class="comment-body">{{ comment.body }}</div>
{% set c_atts = comment.attachments.all() %}
{% if c_atts %}
<div class="mt-2 d-flex flex-wrap gap-2">
<div class="mt-2">
{% for att in c_atts %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>{{ att.filename }}
</a>
{% if att.mime_type and att.mime_type.startswith('image/') %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
alt="{{ att.filename }}"
class="comment-img-thumb"/>
</a>
{% else %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
class="btn btn-secondary btn-sm me-1 mb-1">
<i class="bi bi-download me-1"></i>{{ att.filename }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
@@ -131,11 +141,15 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<textarea id="comment-body" class="form-control" name="body" rows="4" required
placeholder="Add your update, follow-up, or response here…"></textarea>
placeholder="Add your update, follow-up, or response here… (you can also paste images directly)"></textarea>
</div>
<!-- Image preview strip — populated by paste or file picker -->
<div id="img-preview-strip" style="display:none;flex-wrap:wrap;gap:8px;margin-bottom:12px;"></div>
<div class="mb-3">
<label class="form-label">Attachments</label>
<input type="file" class="form-control" name="attachments" multiple
<label class="form-label">Attachments
<span style="font-size:11px;color:var(--muted);font-weight:400;">&nbsp;· images, PDF, documents — or paste an image into the text box</span>
</label>
<input id="attachment-input" type="file" class="form-control" name="attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div>
{% if current_user.is_it_staff %}
@@ -181,6 +195,75 @@ if (typeof socket !== 'undefined') {
});
}
// ── Pasted-image accumulator ──────────────────────────────────────────────────
// Files added via clipboard paste are stored here and merged into the FormData
// on submit, since a paste event cannot modify a real <input type=file>.
let pastedFiles = [];
function rebuildPreviewStrip() {
// Collect all files: pasted images + files chosen via the picker
const pickerFiles = Array.from(document.getElementById('attachment-input').files || []);
const allFiles = [...pastedFiles, ...pickerFiles];
const strip = document.getElementById('img-preview-strip');
if (allFiles.length === 0) { strip.style.display = 'none'; strip.innerHTML = ''; return; }
strip.style.display = 'flex';
strip.innerHTML = '';
allFiles.forEach((file, idx) => {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position:relative;display:inline-block;';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--border);cursor:pointer;';
img.title = file.name;
img.src = URL.createObjectURL(file);
img.onclick = () => window.open(img.src, '_blank');
wrapper.appendChild(img);
} else {
const label = document.createElement('div');
label.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);text-align:center;padding:4px;word-break:break-all;background:var(--surface);';
label.textContent = file.name;
wrapper.appendChild(label);
}
// Only pasted files (idx < pastedFiles.length) get a remove button
// Picker files are managed via the native file input
if (idx < pastedFiles.length) {
const rm = document.createElement('button');
rm.type = 'button';
rm.innerHTML = '&times;';
rm.style.cssText = 'position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:var(--danger);color:#fff;font-size:12px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;';
rm.onclick = () => { pastedFiles.splice(idx, 1); rebuildPreviewStrip(); };
wrapper.appendChild(rm);
}
strip.appendChild(wrapper);
});
}
// Paste images from clipboard into the textarea
document.getElementById('comment-body').addEventListener('paste', function(e) {
const items = (e.clipboardData || window.clipboardData).items;
let caught = false;
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
// Give the file a deterministic name
const ext = item.type.split('/')[1] || 'png';
const named = new File([file], `paste-${Date.now()}.${ext}`, { type: item.type });
pastedFiles.push(named);
caught = true;
}
}
}
if (caught) { e.preventDefault(); rebuildPreviewStrip(); }
});
// Keep preview in sync when user changes the file picker selection
document.getElementById('attachment-input').addEventListener('change', rebuildPreviewStrip);
// ── AJAX comment form submit ──────────────────────────────────────────────────
document.getElementById('comment-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -194,7 +277,10 @@ document.getElementById('comment-form').addEventListener('submit', async functio
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Posting…';
// Build FormData manually so we can append pasted files (which are not in
// the real <input type=file> and therefore not included automatically).
const fd = new FormData(this);
pastedFiles.forEach(f => fd.append('attachments', f, f.name));
try {
const resp = await fetch(window.location.pathname, {
@@ -204,15 +290,12 @@ document.getElementById('comment-form').addEventListener('submit', async functio
});
if (resp.redirected || resp.ok) {
// Success — clear the form
document.getElementById('comment-body').value = '';
const fileInput = this.querySelector('input[type="file"]');
if (fileInput) fileInput.value = '';
document.getElementById('attachment-input').value = '';
pastedFiles = [];
rebuildPreviewStrip();
const internalCb = document.getElementById('is_internal');
if (internalCb) internalCb.checked = false;
// The server will push the new comment via socket to all viewers including us.
// Fetch the latest comments to make sure we have it (handles the case where
// the socket push arrives before or after the AJAX response).
await refreshComments();
} else {
err.textContent = 'Failed to post comment (HTTP ' + resp.status + '). Please try again.';
@@ -314,11 +397,16 @@ function buildCommentEl(c) {
</button>
</form>`
: '';
const atts = (c.attachments || []).map(a =>
`<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>${a.filename}
</a>`
).join('');
const atts = (c.attachments || []).map(a => {
if (a.is_image) {
return `<a href="/attachments/${a.id}" target="_blank" class="comment-img-link">
<img src="/attachments/${a.id}" alt="${a.filename}" class="comment-img-thumb"/>
</a>`;
}
return `<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm me-1 mb-1">
<i class="bi bi-download me-1"></i>${a.filename}
</a>`;
}).join('');
wrap.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2">
@@ -344,6 +432,18 @@ function buildCommentEl(c) {
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .6s linear infinite; display: inline-block; }
.comment-img-thumb {
max-width: 220px;
max-height: 180px;
border-radius: 6px;
border: 1px solid var(--border);
cursor: pointer;
display: block;
margin: 4px 4px 0 0;
transition: opacity .15s;
}
.comment-img-thumb:hover { opacity: .85; }
.comment-img-link { display: inline-block; }
</style>
</div>