fbla26/src/lib/components/custom/image-upload/image-upload.svelte
DragonDuck24 3925f4e67a
Some checks failed
ci / deploy (push) Blocked by required conditions
ci / docker_image (push) Has been cancelled
settings and fixes
2026-04-04 17:37:17 -05:00

181 lines
3.7 KiB
Svelte

<script lang="ts">
import ImagePlus from '@lucide/svelte/icons/image-plus';
import X from '@lucide/svelte/icons/x';
import { onDestroy } from 'svelte';
let {
name = 'image',
required = false,
disabled = false,
onSelect = null,
previewUrl = null
}: {
name?: string;
required?: boolean;
disabled?: boolean;
onSelect?: ((file: File) => void) | null;
previewUrl?: string | null;
} = $props();
let inputEl = $state<HTMLInputElement | null>(null);
let dragging = $state(false);
// derived value instead of initializing from required
let canRemove = $derived(!required);
function openFileDialog() {
if (!disabled) {
inputEl?.click();
}
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const selected = files[0];
if (!selected.type.startsWith('image/')) return;
cleanupPreview();
previewUrl = URL.createObjectURL(selected);
if (onSelect) onSelect(selected);
}
function onInputChange(e: Event) {
const target = e.target as HTMLInputElement;
handleFiles(target.files);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
if (disabled) return;
handleFiles(e.dataTransfer?.files ?? null);
}
function cleanupPreview() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
}
onDestroy(cleanupPreview);
</script>
<div>
<input
bind:this={inputEl}
type="file"
name={name}
accept="image/*"
required={required}
disabled={disabled}
capture="environment"
onchange={onInputChange}
formaction="/item?/describe"
hidden
/>
<button
class="dropzone"
class:has-image={!!previewUrl}
class:dragging={dragging}
onclick={openFileDialog}
ondragover={(e) => {e.preventDefault(); dragging = !disabled;}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
type="button"
>
{#if previewUrl}
<img src={previewUrl} alt="Selected preview" />
<div class="overlay">
<ImagePlus size={24} />
<span>Replace image</span>
</div>
{:else}
<div class="placeholder py-4">
<ImagePlus size={32} />
<p>
Click or drag an image here
<span class="text-error">{required ? '*' : ''}</span>
</p>
</div>
{/if}
</button>
{#if previewUrl && canRemove}
<button
class="hover:text-destructive p-2"
onclick={cleanupPreview}
type="button"
>
<X size={24} class="inline" />
<span class="inline align-middle">Remove image</span>
</button>
{/if}
</div>
<style>
.dropzone {
position: relative;
width: 100%;
max-height: 200px;
min-height: 80px;
border-radius: 12px;
border: 2px dashed var(--border);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: border-color 0.2s, background 0.2s;
}
.dropzone.dragging {
border-color: var(--primary);
background: var(--muted);
}
.placeholder {
text-align: center;
color: var(--muted-foreground);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
color: white;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
text-align: center;
font-size: 0.9rem;
}
.has-image:hover .overlay {
opacity: 1;
}
.has-image:hover img {
filter: brightness(0.8);
}
</style>