From b1ff3ca80f7094fcde43eb8a197848acc00b9e46 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 22 Apr 2026 23:13:33 +0200 Subject: [PATCH] ui: stronger drop-zone affordance after file load Drop zone now has three explicit visual states: empty (default cloud + instructions), ready (large file icon with a checkmark badge, filename in big, size + click-to-replace + remove button, solid emerald border and tinted background), and error (red icon + message, auto-clears after 2.5s). Covers both drag-drop and click-to-browse flows, validates size client-side, and scales up slightly during dragover. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/server/templates/dashboard.html | 138 ++++++++++++++++++----- 1 file changed, 109 insertions(+), 29 deletions(-) diff --git a/internal/server/templates/dashboard.html b/internal/server/templates/dashboard.html index c86fc78..593027d 100644 --- a/internal/server/templates/dashboard.html +++ b/internal/server/templates/dashboard.html @@ -29,16 +29,53 @@
@@ -78,41 +115,84 @@ (function () { const zone = document.getElementById('dropZone'); const input = document.getElementById('dropInput'); - const hint = document.getElementById('dropHint'); - const form = document.getElementById('publishForm'); + const filename = document.getElementById('dropFilename'); + const sizeEl = document.getElementById('dropSize'); + const errorEl = document.getElementById('dropError'); + const clearBtn = document.getElementById('dropClear'); if (!zone || !input) return; - const ACTIVE = ['border-indigo-500', 'bg-indigo-500/10']; + const panels = { + empty: zone.querySelector('[data-state="empty"]'), + ready: zone.querySelector('[data-state="ready"]'), + error: zone.querySelector('[data-state="error"]'), + }; + + const READY = ['border-emerald-500', '!border-solid', 'bg-emerald-500/5']; + const OVER = ['border-indigo-500', 'bg-indigo-500/10', 'scale-[1.01]']; + const ERROR = ['border-red-500', 'bg-red-500/5']; + + function setState(name) { + for (const key of Object.keys(panels)) { + const show = key === name; + panels[key].classList.toggle('hidden', !show); + panels[key].classList.toggle('flex', show); + } + zone.classList.remove(...READY, ...OVER, ...ERROR); + if (name === 'ready') zone.classList.add(...READY); + else if (name === 'error') zone.classList.add(...ERROR); + } + const prevent = e => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover'].forEach(ev => - zone.addEventListener(ev, e => { prevent(e); zone.classList.add(...ACTIVE); })); + zone.addEventListener(ev, e => { prevent(e); zone.classList.add(...OVER); })); ['dragleave', 'drop'].forEach(ev => - zone.addEventListener(ev, e => { prevent(e); zone.classList.remove(...ACTIVE); })); + zone.addEventListener(ev, e => { prevent(e); zone.classList.remove(...OVER); })); zone.addEventListener('drop', e => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (!f) return; - if (!/\.html?$/i.test(f.name) && f.type !== 'text/html') { - hint.textContent = 'Only .html files are accepted'; - hint.classList.add('text-red-400'); - return; - } - // DataTransfer into the input so form submit picks it up. - const dt = new DataTransfer(); - dt.items.add(f); - input.files = dt.files; - showSelection(f); + handleFile(f); }); input.addEventListener('change', () => { - if (input.files && input.files[0]) showSelection(input.files[0]); + const f = input.files && input.files[0]; + if (f) handleFile(f); }); - function showSelection(f) { - const kb = (f.size / 1024).toFixed(1); - hint.textContent = `${f.name} · ${kb} KB`; - hint.classList.remove('text-red-400'); - hint.classList.add('text-indigo-300'); + clearBtn && clearBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + input.value = ''; + setState('empty'); + }); + + function handleFile(f) { + if (!/\.html?$/i.test(f.name) && f.type !== 'text/html') { + errorEl.textContent = `Not an HTML file: ${f.name}`; + setState('error'); + setTimeout(() => { if (!input.files[0]) setState('empty'); }, 2500); + return; + } + if (f.size > 5 * 1024 * 1024) { + errorEl.textContent = `Too large (${fmtSize(f.size)}, max 5 MB)`; + setState('error'); + return; + } + // Reflect drop selection back into the input so the form submits it. + try { + const dt = new DataTransfer(); + dt.items.add(f); + input.files = dt.files; + } catch (_) { /* no-op: input.files already set if via click */ } + filename.textContent = f.name; + sizeEl.textContent = fmtSize(f.size); + setState('ready'); + } + + function fmtSize(n) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; } })();