feat: full-screen drag-and-drop upload zone Document-level drag events trigger a full-screen overlay with dark backdrop and dashed red border, accepting the file on drop anywhere on the page. File can still be selected via button click. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-flash
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 21:14:24 +0200
1 files changed,
118 insertions(+),
34 deletions(-)
M
web/src/routes/upload/+page.svelte
→
web/src/routes/upload/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; import { uploadImage, suggestTags } from '$lib/api'; let file = $state<File | null>(null);@@ -8,6 +9,56 @@ let success = $state('');
let loading = $state(false); let suggestions = $state<string[]>([]); let showSuggestions = $state(false); + let dragActive = $state(false); + + let fileInput = $state<HTMLInputElement | null>(null); + + let dragCounter = $state(0); + + function onDocumentDragEnter(e: DragEvent) { + if (!e.dataTransfer?.types.includes('Files')) return; + dragCounter++; + dragActive = true; + } + + function onDocumentDragLeave(e: DragEvent) { + dragCounter--; + if (dragCounter <= 0) { + dragCounter = 0; + dragActive = false; + } + } + + function onDocumentDragOver(e: DragEvent) { + e.preventDefault(); + } + + function onDocumentDrop(e: DragEvent) { + e.preventDefault(); + dragCounter = 0; + dragActive = false; + const dropped = e.dataTransfer?.files?.[0]; + if (!dropped) return; + if (dropped.type.startsWith('image/') || dropped.type.startsWith('video/')) { + file = dropped; + } else { + error = 'Unsupported file type. Please drop an image or video.'; + } + } + + onMount(() => { + document.addEventListener('dragenter', onDocumentDragEnter); + document.addEventListener('dragleave', onDocumentDragLeave); + document.addEventListener('dragover', onDocumentDragOver); + document.addEventListener('drop', onDocumentDrop); + }); + + onDestroy(() => { + document.removeEventListener('dragenter', onDocumentDragEnter); + document.removeEventListener('dragleave', onDocumentDragLeave); + document.removeEventListener('dragover', onDocumentDragOver); + document.removeEventListener('drop', onDocumentDrop); + }); async function onTagInput() { const q = tags.split(',').pop()?.trim() || '';@@ -33,6 +84,10 @@ tags = parts.join(', ') + ', ';
showSuggestions = false; } + function openFilePicker() { + fileInput?.click(); + } + async function handleSubmit(e: Event) { e.preventDefault(); if (!file) return;@@ -53,6 +108,15 @@ }
} </script> +{#if dragActive} + <div class="drop-overlay" class:loading> + <div class="drop-content"> + <div class="drop-icon">⬇</div> + <div class="drop-text">DROP MEDIA HERE</div> + </div> + </div> +{/if} + <div class="page-head"> <h1>UPLOAD</h1> <div class="head-line"></div>@@ -66,18 +130,15 @@ {#if success}
<p class="success">{success} <a href="/browse">BROWSE</a></p> {/if} - <label> - <span class="label-text">FILE</span> - <div class="file-wrap"> - <input type="file" id="file-input" accept="image/*,video/mp4,video/webm" required - onchange={(e) => { file = (e.target as HTMLInputElement).files?.[0] ?? null; }} - disabled={loading} - /> - <label for="file-input" class="file-label"> - {file ? file.name : 'SELECT MEDIA'} - </label> - </div> - </label> + <input type="file" id="file-input" accept="image/*,video/mp4,video/webm" + bind:this={fileInput} + onchange={(e) => { file = (e.target as HTMLInputElement).files?.[0] ?? null; }} + disabled={loading} + /> + + <button type="button" class="btn select-btn" onclick={openFilePicker} disabled={loading}> + {file ? `CHANGE FILE: ${file.name}` : 'SELECT MEDIA'} + </button> <label> <span class="label-text">TAGS (COMMA-SEPARATED)</span>@@ -123,17 +184,6 @@ gap: 1rem;
max-width: 480px; } - .label-text { - font-family: 'Oswald', sans-serif; - font-weight: 500; - font-size: 0.75rem; - letter-spacing: 0.15em; - color: #888; - } - - .file-wrap { - position: relative; - } #file-input { position: absolute; width: 1px;@@ -141,21 +191,55 @@ height: 1px;
opacity: 0; pointer-events: none; } - .file-label { + + .drop-overlay { + position: fixed; + inset: 0; + z-index: 9999; display: flex; align-items: center; - padding: 0.6rem 0.75rem; - border: 2px solid #1a1a1a; - font-family: 'Inter', sans-serif; - font-size: 0.85rem; + justify-content: center; + background: rgba(13, 13, 13, 0.88); + border: 3px dashed #c62828; + pointer-events: none; + } + + .drop-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + pointer-events: none; + } + + .drop-icon { + font-size: 4rem; + line-height: 1; + opacity: 0.9; + } + + .drop-text { + font-family: 'Oswald', sans-serif; + font-weight: 700; + font-size: 1.8rem; + letter-spacing: 0.15em; + color: #c62828; + text-transform: uppercase; + } + + .select-btn { + align-self: stretch !important; + } + .select-btn:hover { + background: #c62828; + } + + .label-text { + font-family: 'Oswald', sans-serif; font-weight: 500; + font-size: 0.75rem; + letter-spacing: 0.15em; color: #888; - background: #fff; - cursor: pointer; - transition: border-color 0.15s; - } - .file-label:hover { - border-color: #c62828; } input[type="text"] {