Drag and drop attachments
This commit is contained in:
parent
690850773e
commit
acd9a03269
|
|
@ -5,6 +5,7 @@ COPY package.json package-lock.json* ./
|
|||
RUN npm install
|
||||
COPY web/static/css/input.css web/static/css/input.css
|
||||
COPY web/templates/ web/templates/
|
||||
COPY web/static/js/ web/static/js/
|
||||
COPY internal/handlers/ internal/handlers/
|
||||
RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* initAttachmentZone — wire up drag-and-drop, clipboard paste, and file-picker
|
||||
* for a form that already has a multipart file input.
|
||||
*
|
||||
* Uses a DataTransfer object as a mutable file store so that
|
||||
* fileInput.files always reflects the full set of chosen files and
|
||||
* native form submission works unchanged.
|
||||
*/
|
||||
function initAttachmentZone({ formEl, fileInput, dropZone, fileListEl, pasteTarget }) {
|
||||
const dt = new DataTransfer();
|
||||
let dragCounter = 0;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function fileKey(f) {
|
||||
return f.name + '|' + f.size + '|' + f.lastModified;
|
||||
}
|
||||
|
||||
function currentKeys() {
|
||||
const keys = new Set();
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
keys.add(fileKey(dt.files[i]));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function addFiles(files) {
|
||||
const keys = currentKeys();
|
||||
for (const f of files) {
|
||||
if (!keys.has(fileKey(f))) {
|
||||
dt.items.add(f);
|
||||
keys.add(fileKey(f));
|
||||
}
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
dt.items.remove(index);
|
||||
sync();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
fileInput.files = dt.files;
|
||||
renderList();
|
||||
}
|
||||
|
||||
// ── File list rendering ─────────────────────────────────────────────
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
fileListEl.innerHTML = '';
|
||||
if (dt.files.length === 0) {
|
||||
fileListEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
fileListEl.classList.remove('hidden');
|
||||
|
||||
for (let i = 0; i < dt.files.length; i++) {
|
||||
const f = dt.files[i];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between rounded-md bg-gray-50 px-3 py-1.5 text-sm';
|
||||
|
||||
const info = document.createElement('span');
|
||||
info.className = 'text-gray-700 truncate';
|
||||
info.textContent = f.name;
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'text-gray-400 ml-2 whitespace-nowrap';
|
||||
size.textContent = formatSize(f.size);
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center min-w-0';
|
||||
left.appendChild(info);
|
||||
left.appendChild(size);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'ml-2 text-gray-400 hover:text-red-500 text-xs font-bold';
|
||||
btn.textContent = '\u00d7';
|
||||
btn.setAttribute('aria-label', 'Remove ' + f.name);
|
||||
btn.addEventListener('click', function () { removeFile(i); });
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(btn);
|
||||
fileListEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag-and-drop ───────────────────────────────────────────────────
|
||||
|
||||
function highlight() {
|
||||
dropZone.classList.add('ring-blue-400', 'bg-blue-50', 'border-blue-400');
|
||||
}
|
||||
|
||||
function unhighlight() {
|
||||
dropZone.classList.remove('ring-blue-400', 'bg-blue-50', 'border-blue-400');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('dragenter', function (e) {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
highlight();
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
unhighlight();
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
unhighlight();
|
||||
if (e.dataTransfer && e.dataTransfer.files.length) {
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
// ── File picker (additive) ──────────────────────────────────────────
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
addFiles(fileInput.files);
|
||||
});
|
||||
|
||||
// ── Clipboard paste ─────────────────────────────────────────────────
|
||||
|
||||
function pasteTimestamp() {
|
||||
const d = new Date();
|
||||
const pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return d.getFullYear() +
|
||||
pad(d.getMonth() + 1) +
|
||||
pad(d.getDate()) + '-' +
|
||||
pad(d.getHours()) +
|
||||
pad(d.getMinutes()) +
|
||||
pad(d.getSeconds());
|
||||
}
|
||||
|
||||
if (pasteTarget) {
|
||||
pasteTarget.addEventListener('paste', function (e) {
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
|
||||
var files = [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === 'file') {
|
||||
var f = items[i].getAsFile();
|
||||
if (!f) continue;
|
||||
// Rename generic pasted names like "image.png"
|
||||
if (/^image\.\w+$/.test(f.name)) {
|
||||
var ext = f.name.split('.').pop();
|
||||
var renamed = new File([f], 'paste-' + pasteTimestamp() + '.' + ext, { type: f.type });
|
||||
files.push(renamed);
|
||||
} else {
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
addFiles(files);
|
||||
// Don't prevent default — let text pastes through
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
|
||||
<script type="module">
|
||||
if (document.querySelector('pre.mermaid')) {
|
||||
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
|
||||
|
|
|
|||
|
|
@ -117,29 +117,31 @@
|
|||
{{end}}
|
||||
|
||||
<!-- Add Comment -->
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/comments" enctype="multipart/form-data" class="mt-6">
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/comments" enctype="multipart/form-data" class="mt-6" id="comment-form">
|
||||
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
|
||||
<div>
|
||||
<label for="body" class="sr-only">Add a comment</label>
|
||||
<div id="comment-drop-zone" class="rounded-md border-2 border-dashed border-gray-300 transition-colors">
|
||||
<textarea name="body" id="body" rows="3"
|
||||
placeholder="Add a comment... (Markdown supported)"
|
||||
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
|
||||
class="block w-full border-0 rounded-t-md px-3 py-2 focus:ring-0 focus:outline-none bg-transparent"></textarea>
|
||||
<div class="border-t border-gray-200 px-3 py-2 text-xs text-gray-400">
|
||||
Drop files, paste images, or
|
||||
<label for="comment-attachments" class="cursor-pointer text-blue-600 hover:text-blue-500 font-medium">browse</label>
|
||||
<input type="file" name="attachments" id="comment-attachments" multiple class="hidden">
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label for="attachments" class="text-sm text-gray-500 cursor-pointer hover:text-gray-700">
|
||||
Attach files
|
||||
<input type="file" name="attachments" id="attachments" multiple class="hidden">
|
||||
</label>
|
||||
<span id="file-count" class="text-xs text-gray-400 ml-2"></span>
|
||||
</div>
|
||||
<div id="comment-file-list" class="mt-2 space-y-1 hidden"></div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/static/js/attachments.js"></script>
|
||||
<script>
|
||||
document.getElementById('attachments').addEventListener('change', function() {
|
||||
var count = this.files.length;
|
||||
document.getElementById('file-count').textContent = count > 0 ? count + ' file(s) selected' : '';
|
||||
initAttachmentZone({
|
||||
formEl: document.getElementById('comment-form'),
|
||||
fileInput: document.getElementById('comment-attachments'),
|
||||
dropZone: document.getElementById('comment-drop-zone'),
|
||||
fileListEl: document.getElementById('comment-file-list'),
|
||||
pasteTarget: document.getElementById('body'),
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,10 +45,16 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label for="attachments" class="block text-sm font-medium text-gray-700">Attachments</label>
|
||||
<input type="file" name="attachments" id="attachments" multiple
|
||||
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
<p class="mt-1 text-xs text-gray-500">Optional. You can attach multiple files.</p>
|
||||
<label class="block text-sm font-medium text-gray-700">Attachments</label>
|
||||
<div id="drop-zone" class="mt-1 rounded-md border-2 border-dashed border-gray-300 px-4 py-6 text-center transition-colors">
|
||||
<p class="text-sm text-gray-500">
|
||||
Drag & drop files here, paste from clipboard, or
|
||||
<label for="attachments" class="cursor-pointer text-blue-600 hover:text-blue-500 font-medium">browse</label>
|
||||
</p>
|
||||
<input type="file" name="attachments" id="attachments" multiple class="hidden">
|
||||
<p class="mt-1 text-xs text-gray-400">You can attach multiple files</p>
|
||||
</div>
|
||||
<div id="file-list" class="mt-2 space-y-1 hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
|
|
@ -57,4 +63,14 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/static/js/attachments.js"></script>
|
||||
<script>
|
||||
initAttachmentZone({
|
||||
formEl: document.querySelector('form'),
|
||||
fileInput: document.getElementById('attachments'),
|
||||
dropZone: document.getElementById('drop-zone'),
|
||||
fileListEl: document.getElementById('file-list'),
|
||||
pasteTarget: document.getElementById('description'),
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue