Drag and drop attachments

This commit is contained in:
Matthew Knight 2026-02-16 00:58:04 -08:00
parent 690850773e
commit acd9a03269
No known key found for this signature in database
5 changed files with 218 additions and 19 deletions

View File

@ -5,6 +5,7 @@ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
COPY web/static/css/input.css web/static/css/input.css COPY web/static/css/input.css web/static/css/input.css
COPY web/templates/ web/templates/ COPY web/templates/ web/templates/
COPY web/static/js/ web/static/js/
COPY internal/handlers/ internal/handlers/ COPY internal/handlers/ internal/handlers/
RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify

View File

@ -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
}
});
}
}

View File

@ -14,6 +14,7 @@
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
</div> </div>
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
<script type="module"> <script type="module">
if (document.querySelector('pre.mermaid')) { if (document.querySelector('pre.mermaid')) {
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs'); const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');

View File

@ -117,29 +117,31 @@
{{end}} {{end}}
<!-- Add Comment --> <!-- 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}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div id="comment-drop-zone" class="rounded-md border-2 border-dashed border-gray-300 transition-colors">
<label for="body" class="sr-only">Add a comment</label>
<textarea name="body" id="body" rows="3" <textarea name="body" id="body" rows="3"
placeholder="Add a comment... (Markdown supported)" 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>
<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>
<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> <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> </div>
</form> </form>
<script src="/static/js/attachments.js"></script>
<script> <script>
document.getElementById('attachments').addEventListener('change', function() { initAttachmentZone({
var count = this.files.length; formEl: document.getElementById('comment-form'),
document.getElementById('file-count').textContent = count > 0 ? count + ' file(s) selected' : ''; fileInput: document.getElementById('comment-attachments'),
dropZone: document.getElementById('comment-drop-zone'),
fileListEl: document.getElementById('comment-file-list'),
pasteTarget: document.getElementById('body'),
}); });
</script> </script>
</div> </div>

View File

@ -45,10 +45,16 @@
</div> </div>
<div> <div>
<label for="attachments" class="block text-sm font-medium text-gray-700">Attachments</label> <label class="block text-sm font-medium text-gray-700">Attachments</label>
<input type="file" name="attachments" id="attachments" multiple <div id="drop-zone" class="mt-1 rounded-md border-2 border-dashed border-gray-300 px-4 py-6 text-center transition-colors">
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="text-sm text-gray-500">
<p class="mt-1 text-xs text-gray-500">Optional. You can attach multiple files.</p> 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>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
@ -57,4 +63,14 @@
</div> </div>
</form> </form>
</div> </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}} {{end}}