Upgrade allow adding image in ticket details page comment section
This commit is contained in:
@@ -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;"> · 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 = '×';
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user