From acd9a0326961ea4c68640eb0a645cef571b0fa3b Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Mon, 16 Feb 2026 00:58:04 -0800 Subject: [PATCH] Drag and drop attachments --- Dockerfile | 1 + web/static/js/attachments.js | 179 ++++++++++++++++++++++++ web/templates/layouts/base.html | 1 + web/templates/pages/tickets/detail.html | 32 +++-- web/templates/pages/tickets/new.html | 24 +++- 5 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 web/static/js/attachments.js diff --git a/Dockerfile b/Dockerfile index c3e4873..4c21b03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/web/static/js/attachments.js b/web/static/js/attachments.js new file mode 100644 index 0000000..f4809ab --- /dev/null +++ b/web/static/js/attachments.js @@ -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 + } + }); + } +} diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index 6d675c1..debf7f3 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -14,6 +14,7 @@ {{block "content" .}}{{end}} + diff --git a/web/templates/pages/tickets/new.html b/web/templates/pages/tickets/new.html index f08944c..5ee6742 100644 --- a/web/templates/pages/tickets/new.html +++ b/web/templates/pages/tickets/new.html @@ -45,10 +45,16 @@
- - -

Optional. You can attach multiple files.

+ +
+

+ Drag & drop files here, paste from clipboard, or + +

+ +

You can attach multiple files

+
+
@@ -57,4 +63,14 @@
+ + {{end}}