2026-02-03 22:18:58 +00:00
|
|
|
<script lang="ts">
|
2026-09-21 20:25:01 +00:00
|
|
|
import cx from "classnames"
|
2026-07-15 16:30:17 +00:00
|
|
|
import type {Maybe} from "@welshman/lib"
|
2026-02-03 22:18:58 +00:00
|
|
|
import {randomId} from "@welshman/lib"
|
|
|
|
|
import {removeAt, insertAt} from "@welshman/lib"
|
|
|
|
|
import {preventDefault, stopPropagation} from "@lib/html"
|
|
|
|
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
|
|
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
|
|
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
|
|
|
import Button from "@lib/components/Button.svelte"
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
value: (string | File)[]
|
|
|
|
|
multiple?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let {value = $bindable(), multiple = true}: Props = $props()
|
|
|
|
|
|
|
|
|
|
const id = randomId()
|
|
|
|
|
|
|
|
|
|
const getImageUrl = (item: string | File): string => {
|
2026-09-21 16:48:35 +00:00
|
|
|
if (typeof item === "string") {
|
|
|
|
|
return item
|
|
|
|
|
}
|
2026-02-03 22:18:58 +00:00
|
|
|
return URL.createObjectURL(item)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addFiles = (files: FileList | File[]) => {
|
|
|
|
|
const newFiles = Array.from(files).filter(file => file.type.startsWith("image/"))
|
2026-09-21 20:25:01 +00:00
|
|
|
|
|
|
|
|
value = multiple ? [...value, ...newFiles] : newFiles.slice(0, 1)
|
2026-02-03 22:18:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removeItem = (index: number) => {
|
|
|
|
|
value = removeAt(index, value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onFileChange = (e: Event) => {
|
|
|
|
|
const target = e.target as HTMLInputElement
|
|
|
|
|
if (target.files?.length) {
|
|
|
|
|
addFiles(target.files)
|
|
|
|
|
target.value = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onDrop = (e: Event) => {
|
|
|
|
|
dropActive = false
|
|
|
|
|
const dragEvent = e as DragEvent
|
|
|
|
|
if (dragEvent.dataTransfer?.files?.length) {
|
|
|
|
|
addFiles(dragEvent.dataTransfer.files)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onDragEnter = (e: Event) => {
|
|
|
|
|
dropActive = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onDragOver = (e: Event) => {
|
|
|
|
|
dropActive = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onDragLeave = (e: Event) => {
|
|
|
|
|
dropActive = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 16:30:17 +00:00
|
|
|
let draggedIndex: Maybe<number> = $state()
|
2026-02-03 22:18:58 +00:00
|
|
|
let dropActive = $state(false)
|
|
|
|
|
|
|
|
|
|
const handleDragStart = (e: DragEvent, index: number) => {
|
|
|
|
|
draggedIndex = index
|
|
|
|
|
|
|
|
|
|
if (e.dataTransfer) {
|
|
|
|
|
e.dataTransfer.effectAllowed = "move"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (e: DragEvent, index: number) => {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
2026-07-15 16:30:17 +00:00
|
|
|
if (draggedIndex !== undefined && draggedIndex !== index) {
|
2026-02-03 22:18:58 +00:00
|
|
|
value = insertAt(index, value[draggedIndex], removeAt(draggedIndex, value))
|
|
|
|
|
draggedIndex = index
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDragEnd = () => {
|
2026-07-15 16:30:17 +00:00
|
|
|
draggedIndex = undefined
|
2026-02-03 22:18:58 +00:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-col gap-2">
|
2026-09-21 20:25:01 +00:00
|
|
|
<div class={cx("grid gap-3", multiple ? "grid-cols-3" : "grid-cols-1")} role="list">
|
2026-02-03 22:18:58 +00:00
|
|
|
{#each value as item, index (index)}
|
|
|
|
|
<div
|
2026-09-21 20:25:01 +00:00
|
|
|
class={cx(
|
|
|
|
|
"border-line bg-surface-more relative overflow-hidden rounded-2xl border border-solid",
|
|
|
|
|
multiple ? "aspect-square cursor-move" : "aspect-video",
|
|
|
|
|
)}
|
|
|
|
|
style:border-color={draggedIndex === index ? "var(--primary)" : undefined}
|
|
|
|
|
draggable={multiple}
|
2026-02-03 22:18:58 +00:00
|
|
|
role="listitem"
|
2026-09-21 20:25:01 +00:00
|
|
|
aria-label={multiple ? "Draggable image" : "Image"}
|
2026-02-03 22:18:58 +00:00
|
|
|
ondragstart={e => handleDragStart(e, index)}
|
|
|
|
|
ondragover={e => handleDragOver(e, index)}
|
|
|
|
|
ondragend={handleDragEnd}>
|
2026-09-21 20:25:01 +00:00
|
|
|
<img src={getImageUrl(item)} alt="Upload preview" class="h-full w-full object-cover" />
|
2026-02-03 22:18:58 +00:00
|
|
|
<Button
|
2026-09-21 20:25:01 +00:00
|
|
|
aria-label="Remove image"
|
2026-09-10 18:21:49 +00:00
|
|
|
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
|
2026-02-03 22:18:58 +00:00
|
|
|
onclick={() => removeItem(index)}>
|
2026-09-10 18:21:49 +00:00
|
|
|
<Icon icon={CloseCircle} size={4} />
|
2026-02-03 22:18:58 +00:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
2026-09-21 20:25:01 +00:00
|
|
|
{#if multiple || value.length === 0}
|
|
|
|
|
<label
|
|
|
|
|
for={id}
|
|
|
|
|
class={cx(
|
|
|
|
|
"border-line flex cursor-pointer items-center justify-center rounded-2xl border-2 border-dashed p-3 text-sm",
|
|
|
|
|
multiple ? "aspect-square" : "aspect-video",
|
|
|
|
|
)}
|
|
|
|
|
style:border-color={dropActive ? "var(--primary)" : undefined}
|
|
|
|
|
aria-label={multiple
|
|
|
|
|
? "Drag and drop images here or click to select"
|
|
|
|
|
: "Drag and drop an image here or click to select"}
|
|
|
|
|
ondragenter={stopPropagation(preventDefault(onDragEnter))}
|
|
|
|
|
ondragover={stopPropagation(preventDefault(onDragOver))}
|
|
|
|
|
ondragleave={stopPropagation(preventDefault(onDragLeave))}
|
|
|
|
|
ondrop={stopPropagation(preventDefault(onDrop))}>
|
|
|
|
|
<div class="flex flex-col items-center gap-2 text-center">
|
|
|
|
|
<Icon icon={GallerySend} size={8} />
|
|
|
|
|
<p class="text-content-muted text-sm">
|
|
|
|
|
Drag and drop {multiple ? "images" : "an image"} or click to select
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
{/if}
|
2026-02-03 22:18:58 +00:00
|
|
|
</div>
|
|
|
|
|
<input {id} type="file" accept="image/*" {multiple} onchange={onFileChange} class="hidden" />
|
|
|
|
|
</div>
|