Redesign file uploads: drag-and-drop zones for images and documents
Deploy to LXC / deploy (push) Successful in 20s

New reusable components:
- ImageUpload.svelte: drag-and-drop / click / paste zone with file
  icon, preview of selected filename, caption field, 50MB limit
- DocumentUpload.svelte: same pattern for documents with description
  field instead of caption

Applied to both device and component detail pages, replacing the old
inline file input forms. Cleaner look matching modern upload UIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:23:30 +07:00
parent 7429130630
commit 36f4d4b8d5
4 changed files with 219 additions and 76 deletions
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
action: string;
fieldName?: string;
}
let { action, fieldName = 'document' }: Props = $props();
let dragging = $state(false);
let fileInput: HTMLInputElement;
function handleDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files[0];
if (file) setFile(file);
}
function handlePaste(e: ClipboardEvent) {
const file = e.clipboardData?.files[0];
if (file) {
e.preventDefault();
setFile(file);
}
}
function setFile(file: File) {
if (file.size > 50 * 1024 * 1024) {
alert('File too large. Maximum size is 50MB.');
return;
}
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
let selectedFile = $state<string | null>(null);
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
selectedFile = file?.name ?? null;
}
</script>
<form method="POST" action={action} enctype="multipart/form-data" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') {
selectedFile = null;
if (fileInput) fileInput.value = '';
}
};
}}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mb-3 cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors
{dragging
? 'border-blue-400 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 hover:border-gray-400 dark:border-gray-600 dark:bg-gray-800/50 dark:hover:border-gray-500'}"
onclick={() => fileInput.click()}
ondragover={(e) => { e.preventDefault(); dragging = true; }}
ondragleave={() => { dragging = false; }}
ondrop={handleDrop}
>
<input type="file" name={fieldName} required
bind:this={fileInput}
onchange={handleFileChange}
class="hidden" />
{#if selectedFile}
<div class="text-sm text-gray-700 dark:text-gray-300">
<svg class="mx-auto mb-2 h-8 w-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7" />
</svg>
<p class="font-medium">{selectedFile}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Click Upload below or select a different file</p>
</div>
{:else}
<svg class="mx-auto mb-2 h-10 w-10 text-gray-300 dark:text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" opacity="0.3"/>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM12 17h-1v-3H8l4-4 4 4h-3v3h-1z"/>
</svg>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Select Files to Upload</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">or Drag and Drop, Copy and Paste Files</p>
{/if}
</div>
{#if selectedFile}
<div class="flex items-center gap-2">
<input type="text" name="description" placeholder="Description (optional)"
class="flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
<button type="button" onclick={() => { selectedFile = null; fileInput.value = ''; }}
class="rounded-md px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</button>
</div>
{/if}
</form>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
action: string;
fieldName?: string;
}
let { action, fieldName = 'image' }: Props = $props();
let dragging = $state(false);
let fileInput: HTMLInputElement;
function handleDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) {
setFile(file);
}
}
function handlePaste(e: ClipboardEvent) {
const file = e.clipboardData?.files[0];
if (file && file.type.startsWith('image/')) {
e.preventDefault();
setFile(file);
}
}
function setFile(file: File) {
if (file.size > 50 * 1024 * 1024) {
alert('File too large. Maximum size is 50MB.');
return;
}
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
let selectedFile = $state<string | null>(null);
function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
selectedFile = file?.name ?? null;
}
</script>
<svelte:window onpaste={handlePaste} />
<form method="POST" action={action} enctype="multipart/form-data" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') {
selectedFile = null;
if (fileInput) fileInput.value = '';
}
};
}}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mb-3 cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors
{dragging
? 'border-blue-400 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 hover:border-gray-400 dark:border-gray-600 dark:bg-gray-800/50 dark:hover:border-gray-500'}"
onclick={() => fileInput.click()}
ondragover={(e) => { e.preventDefault(); dragging = true; }}
ondragleave={() => { dragging = false; }}
ondrop={handleDrop}
>
<input type="file" name={fieldName} accept="image/*" required
bind:this={fileInput}
onchange={handleFileChange}
class="hidden" />
{#if selectedFile}
<div class="text-sm text-gray-700 dark:text-gray-300">
<svg class="mx-auto mb-2 h-10 w-10 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7" />
</svg>
<p class="font-medium">{selectedFile}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Click Upload below or select a different file</p>
</div>
{:else}
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" opacity="0.3"/>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM12 17h-1v-3H8l4-4 4 4h-3v3h-1z"/>
</svg>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Select Files to Upload</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">or Drag and Drop, Copy and Paste Files</p>
{/if}
</div>
{#if selectedFile}
<div class="flex items-center gap-2">
<input type="text" name="caption" placeholder="Caption (optional)"
class="flex-1 rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
<button type="button" onclick={() => { selectedFile = null; fileInput.value = ''; }}
class="rounded-md px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</button>
</div>
{/if}
</form>
+7 -38
View File
@@ -2,6 +2,8 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { COMPONENT_CONDITIONS } from '$lib/constants.js'; import { COMPONENT_CONDITIONS } from '$lib/constants.js';
import { formatDate, timeAgo } from '$lib/utils/date.js'; import { formatDate, timeAgo } from '$lib/utils/date.js';
import ImageUpload from '$lib/components/ui/ImageUpload.svelte';
import DocumentUpload from '$lib/components/ui/DocumentUpload.svelte';
let { data, form } = $props(); let { data, form } = $props();
const c = $derived(data.component); const c = $derived(data.component);
@@ -88,30 +90,9 @@
<div class="space-y-6 lg:col-span-2"> <div class="space-y-6 lg:col-span-2">
<!-- Images --> <!-- Images -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between"> <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
<button type="button" onclick={() => (showUploadForm = !showUploadForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showUploadForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showUploadForm} <ImageUpload action="?/uploadImage" />
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
<input type="file" name="image" accept="image/*" required
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && file.size > 50 * 1024 * 1024) {
alert('File too large. Maximum size is 50MB.');
(e.target as HTMLInputElement).value = '';
}
}}
class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="caption" placeholder="Caption (optional)"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.images.length === 0} {#if data.images.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
@@ -330,21 +311,9 @@
<!-- Documents --> <!-- Documents -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between"> <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
<button type="button" onclick={() => (showDocForm = !showDocForm)} <DocumentUpload action="?/uploadDocument" />
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showDocForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showDocForm}
<form method="POST" action="?/uploadDocument" enctype="multipart/form-data" use:enhance class="mb-3 space-y-2">
<input type="file" name="document" required class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="description" placeholder="Description"
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.documents.length === 0} {#if data.documents.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
{:else} {:else}
+6 -38
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js'; import { DEVICE_CONDITIONS, DEVICE_LOG_TYPES } from '$lib/constants.js';
import ImageUpload from '$lib/components/ui/ImageUpload.svelte';
import DocumentUpload from '$lib/components/ui/DocumentUpload.svelte';
import { formatDate, timeAgo } from '$lib/utils/date.js'; import { formatDate, timeAgo } from '$lib/utils/date.js';
let { data, form } = $props(); let { data, form } = $props();
@@ -124,34 +126,13 @@
<div class="space-y-6 lg:col-span-2"> <div class="space-y-6 lg:col-span-2">
<!-- Images --> <!-- Images -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between"> <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
<button onclick={() => (showUploadForm = !showUploadForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showUploadForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if form?.error} {#if form?.error}
<div class="mb-3 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div> <div class="mb-3 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if} {/if}
{#if showUploadForm} <ImageUpload action="?/uploadImage" />
<form method="POST" action="?/uploadImage" enctype="multipart/form-data" use:enhance class="mb-4 flex flex-wrap items-end gap-3">
<input type="file" name="image" accept="image/*" required
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && file.size > 50 * 1024 * 1024) {
alert('File too large. Maximum size is 50MB.');
(e.target as HTMLInputElement).value = '';
}
}}
class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="caption" placeholder="Caption (optional)"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.images.length === 0} {#if data.images.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
@@ -657,22 +638,9 @@
<!-- Documents --> <!-- Documents -->
<div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between"> <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
<button onclick={() => (showDocForm = !showDocForm)}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showDocForm ? 'Cancel' : 'Upload'}
</button>
</div>
{#if showDocForm} <DocumentUpload action="?/uploadDocument" />
<form method="POST" action="?/uploadDocument" enctype="multipart/form-data" use:enhance class="mb-3 space-y-2">
<input type="file" name="document" required class="text-sm text-gray-600 dark:text-gray-400" />
<input type="text" name="description" placeholder="Description"
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Upload</button>
</form>
{/if}
{#if data.documents.length === 0} {#if data.documents.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>