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) <noreply@anthropic.com>
This commit is contained in:
@@ -29,16 +29,53 @@
|
|||||||
<form method="post" action="/api/publish" enctype="multipart/form-data" class="space-y-3" id="publishForm">
|
<form method="post" action="/api/publish" enctype="multipart/form-data" class="space-y-3" id="publishForm">
|
||||||
<label for="dropInput"
|
<label for="dropInput"
|
||||||
id="dropZone"
|
id="dropZone"
|
||||||
class="flex flex-col items-center justify-center text-center cursor-pointer
|
class="relative flex flex-col items-center justify-center text-center cursor-pointer
|
||||||
border-2 border-dashed border-slate-700 hover:border-indigo-500
|
border-2 border-dashed border-slate-700 hover:border-indigo-500
|
||||||
rounded-lg bg-slate-950/50 hover:bg-slate-950 transition-colors
|
rounded-lg bg-slate-950/50 hover:bg-slate-950
|
||||||
|
transition-[border-color,background-color,transform] duration-150
|
||||||
px-6 py-12 text-sm text-slate-400">
|
px-6 py-12 text-sm text-slate-400">
|
||||||
<svg class="w-10 h-10 mb-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
<!-- Empty state -->
|
||||||
d="M7 16a4 4 0 01-.88-7.9A5 5 0 0115.9 6.56 4 4 0 1118 16M8 13l4-4m0 0l4 4m-4-4v10"/>
|
<div class="flex flex-col items-center" data-state="empty">
|
||||||
</svg>
|
<svg class="w-10 h-10 mb-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div><span class="text-indigo-300 font-medium">Drop HTML file here</span> or click to browse</div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
<div class="text-xs text-slate-500 mt-1" id="dropHint">.html · up to 5 MB</div>
|
d="M7 16a4 4 0 01-.88-7.9A5 5 0 0115.9 6.56 4 4 0 1118 16M8 13l4-4m0 0l4 4m-4-4v10"/>
|
||||||
|
</svg>
|
||||||
|
<div><span class="text-indigo-300 font-medium">Drop HTML file here</span> or click to browse</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">.html · up to 5 MB</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ready state (populated after a file is chosen) -->
|
||||||
|
<div class="hidden flex-col items-center w-full max-w-md" data-state="ready">
|
||||||
|
<div class="relative mb-3">
|
||||||
|
<svg class="w-12 h-12 text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute -bottom-1 -right-1 inline-flex items-center justify-center w-5 h-5 rounded-full bg-emerald-500 text-slate-950 text-[10px] font-bold ring-2 ring-slate-900">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs uppercase tracking-wider text-emerald-400 font-semibold">Ready</span>
|
||||||
|
<span class="w-1 h-1 rounded-full bg-slate-700"></span>
|
||||||
|
<span class="text-xs text-slate-500" id="dropSize">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-medium text-slate-100 mt-1 truncate max-w-full" id="dropFilename">file.html</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-2">
|
||||||
|
click to replace ·
|
||||||
|
<button type="button" id="dropClear" class="underline hover:text-slate-200">remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div class="hidden flex-col items-center" data-state="error">
|
||||||
|
<svg class="w-10 h-10 mb-3 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 9v2m0 4h.01M4.93 4.93l14.14 14.14M12 21a9 9 0 110-18 9 9 0 010 18z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-red-300 font-medium" id="dropError">Only .html files are accepted</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">try again</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input id="dropInput" type="file" name="file" accept=".html,text/html" class="sr-only" required>
|
<input id="dropInput" type="file" name="file" accept=".html,text/html" class="sr-only" required>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
@@ -78,41 +115,84 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const zone = document.getElementById('dropZone');
|
const zone = document.getElementById('dropZone');
|
||||||
const input = document.getElementById('dropInput');
|
const input = document.getElementById('dropInput');
|
||||||
const hint = document.getElementById('dropHint');
|
const filename = document.getElementById('dropFilename');
|
||||||
const form = document.getElementById('publishForm');
|
const sizeEl = document.getElementById('dropSize');
|
||||||
|
const errorEl = document.getElementById('dropError');
|
||||||
|
const clearBtn = document.getElementById('dropClear');
|
||||||
if (!zone || !input) return;
|
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(); };
|
const prevent = e => { e.preventDefault(); e.stopPropagation(); };
|
||||||
['dragenter', 'dragover'].forEach(ev =>
|
['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 =>
|
['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 => {
|
zone.addEventListener('drop', e => {
|
||||||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
if (!/\.html?$/i.test(f.name) && f.type !== 'text/html') {
|
handleFile(f);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('change', () => {
|
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) {
|
clearBtn && clearBtn.addEventListener('click', (e) => {
|
||||||
const kb = (f.size / 1024).toFixed(1);
|
e.preventDefault();
|
||||||
hint.textContent = `${f.name} · ${kb} KB`;
|
e.stopPropagation();
|
||||||
hint.classList.remove('text-red-400');
|
input.value = '';
|
||||||
hint.classList.add('text-indigo-300');
|
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`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user