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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
{{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');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<div class="border-t border-gray-200 px-3 py-2 text-xs text-gray-400">
|
||||||
<div class="mt-3 flex items-center justify-between">
|
Drop files, paste images, or
|
||||||
<div>
|
<label for="comment-attachments" class="cursor-pointer text-blue-600 hover:text-blue-500 font-medium">browse</label>
|
||||||
<label for="attachments" class="text-sm text-gray-500 cursor-pointer hover:text-gray-700">
|
<input type="file" name="attachments" id="comment-attachments" multiple class="hidden">
|
||||||
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>
|
||||||
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue