Redesign file uploads: drag-and-drop zones for images and documents
Deploy to LXC / deploy (push) Successful in 20s
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -2,6 +2,8 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import { COMPONENT_CONDITIONS } from '$lib/constants.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();
|
||||
const c = $derived(data.component);
|
||||
@@ -88,30 +90,9 @@
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<!-- Images -->
|
||||
<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="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>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
|
||||
|
||||
{#if showUploadForm}
|
||||
<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}
|
||||
<ImageUpload action="?/uploadImage" />
|
||||
|
||||
{#if data.images.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
|
||||
@@ -330,21 +311,9 @@
|
||||
|
||||
<!-- Documents -->
|
||||
<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="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
|
||||
<button type="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}
|
||||
<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}
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
|
||||
|
||||
<DocumentUpload action="?/uploadDocument" />
|
||||
{#if data.documents.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
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';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -124,34 +126,13 @@
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<!-- Images -->
|
||||
<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="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>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Images</h2>
|
||||
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
{#if showUploadForm}
|
||||
<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}
|
||||
<ImageUpload action="?/uploadImage" />
|
||||
|
||||
{#if data.images.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No images yet.</p>
|
||||
@@ -657,22 +638,9 @@
|
||||
|
||||
<!-- Documents -->
|
||||
<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="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>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Documents</h2>
|
||||
|
||||
{#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}
|
||||
<DocumentUpload action="?/uploadDocument" />
|
||||
|
||||
{#if data.documents.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No documents.</p>
|
||||
|
||||
Reference in New Issue
Block a user