diff --git a/src/app/components/ImageInputModal.svelte b/src/app/components/ImageInputModal.svelte new file mode 100644 index 00000000..178d5ac4 --- /dev/null +++ b/src/app/components/ImageInputModal.svelte @@ -0,0 +1,107 @@ + + + select(url))}> + + + Add an image + Upload a file, or link to an image hosted somewhere else. + + +
+ + or + +
+ + {#snippet label()} +

Image URL

+ {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} + {#if url && !isValid} + Please enter a valid http or https link. + {:else if failed} + That link could not be loaded as an image. + {:else} + The image will be linked from where it's hosted rather than re-uploaded. + {/if} + {/snippet} +
+ {#if isValid && !failed} +
+ Preview (failedUrl = url)} /> +
+ {/if} +
+ + + + +
diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index 939beb55..9babeca3 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -14,7 +14,7 @@ import {rooms} from "@app/core" import {joinRoom} from "@app/access" import {pushToast} from "@app/toast" - import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads" + import {resolveImageInput} from "@app/uploads" import {deriveHasLivekit} from "@app/relays" import {RoomType} from "@app/rooms" @@ -28,6 +28,8 @@ const {url, header, footer, onsubmit, initialValues = {h: randomId()}}: Props = $props() + const compressOptions = {maxWidth: 128, maxHeight: 128} + const values = $state(initialValues) const relayHasLivekit = deriveHasLivekit(url) @@ -43,16 +45,7 @@ room.livekit = roomType === RoomType.Voice - if (imageFile) { - const compressedFile = await compressFileForUpload(imageFile, { - maxWidth: 128, - maxHeight: 128, - }) - - const result = await uploadFileOrFallback(compressedFile) - - room.picture = result.url - } + room.picture = await resolveImageInput(imageFile, imagePreview, compressOptions) const createCommand = await $rooms.createRoom(url, room) const createMessage = await createCommand.publish().waitForError() diff --git a/src/app/components/SpaceEdit.svelte b/src/app/components/SpaceEdit.svelte index 1c6b46d6..439e8f5a 100644 --- a/src/app/components/SpaceEdit.svelte +++ b/src/app/components/SpaceEdit.svelte @@ -18,7 +18,7 @@ import IconInput from "@lib/components/IconInput.svelte" import {pushToast} from "@app/toast" import {clearModals} from "@app/modal" - import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads" + import {resolveImageInput} from "@app/uploads" import {relayManagement, relays} from "@app/core" type Props = { @@ -30,6 +30,8 @@ const values = $state({...initialValues}) + const compressOptions = {maxWidth: 128, maxHeight: 128} + const back = () => history.back() const submit = async () => { @@ -51,11 +53,10 @@ } } - if (imageFile) { - const compressedFile = await compressFileForUpload(imageFile, {maxWidth: 128, maxHeight: 128}) - const result = await uploadFileOrFallback(compressedFile) + const icon = await resolveImageInput(imageFile, imagePreview, compressOptions) - const res = await $relayManagement.forUrl(url).changeRelayIcon(result.url) + if (icon && icon !== initialValues.icon) { + const res = await $relayManagement.forUrl(url).changeRelayIcon(icon) if (res.error) { return pushToast({theme: "error", message: res.error}) diff --git a/src/app/components/hosting/RelayForm.svelte b/src/app/components/hosting/RelayForm.svelte index 6fea2edf..7e1aebcc 100644 --- a/src/app/components/hosting/RelayForm.svelte +++ b/src/app/components/hosting/RelayForm.svelte @@ -28,7 +28,7 @@ import {derivePlans} from "@app/hosting" import {HOSTING_RELAY_DOMAIN} from "@app/env" import {pushToast} from "@app/toast" - import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads" + import {resolveImageInput} from "@app/uploads" type Props = { mode: "create" | "edit" @@ -48,6 +48,8 @@ onSubmit, }: Props = $props() + const compressOptions = {maxWidth: 128, maxHeight: 128} + const slugify = (value: string) => value .toLowerCase() @@ -123,17 +125,11 @@ loading = true try { - if (imageFile) { - const compressedFile = await compressFileForUpload(imageFile, { - maxWidth: 128, - maxHeight: 128, - }) - const result = await uploadFileOrFallback(compressedFile) + const resolvedIcon = await resolveImageInput(imageFile, imagePreview, compressOptions) - icon = result.url - imagePreview = result.url - imageFile = undefined - } + icon = resolvedIcon ?? "" + imagePreview = icon + imageFile = undefined await onSubmit({ plan_id: planId, diff --git a/src/app/uploads.ts b/src/app/uploads.ts index fe33dfca..fca5d923 100644 --- a/src/app/uploads.ts +++ b/src/app/uploads.ts @@ -1,3 +1,4 @@ +import type {Maybe} from "@welshman/lib" import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib" import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" @@ -177,3 +178,18 @@ export const uploadFileOrFallback = async ( return {url: await readFileAsDataUrl(file), tags: []} } + +export const resolveImageInput = async ( + file: Maybe, + url: Maybe, + options: CompressFileOptions = {}, +) => { + if (!file) { + return url + } + + const compressedFile = await compressFileForUpload(file, options) + const result = await uploadFileOrFallback(compressedFile) + + return result.url +} diff --git a/src/lib/components/IconInput.svelte b/src/lib/components/IconInput.svelte index 688576c8..c0f116ba 100644 --- a/src/lib/components/IconInput.svelte +++ b/src/lib/components/IconInput.svelte @@ -4,17 +4,20 @@ import Icon from "@lib/components/Icon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte" import IconPickerButton from "@lib/components/IconPickerButton.svelte" + import ImageInputButton from "@lib/components/ImageInputButton.svelte" type Props = { // The parent uploads this on submit — never `preview`, which may be a data: URL. file?: File | undefined - // An existing hosted URL, or the data: URL of a freshly picked image. + // An existing hosted URL, the data: URL of a freshly picked image, or a URL the user pasted. preview?: string | undefined previewClass?: string } let {file = $bindable(), preview = $bindable(), previewClass = ""}: Props = $props() + const initialUrlValue = $derived(preview?.startsWith("data:") ? undefined : preview) + // Decode a built-in icon's base64 SVG data: URL into a File so it uploads on // submit like any image. const handleIconSelect = (iconUrl: string) => { @@ -31,19 +34,21 @@ file = new File([bytes], "icon.svg", {type: "image/svg+xml"}) } - const handleImageUpload = (event: Event) => { - const selected = (event.target as HTMLInputElement).files?.[0] - - if (selected && selected.type.startsWith("image/")) { - const reader = new FileReader() - - reader.onload = e => { - file = selected - preview = e.target?.result as string - } - - reader.readAsDataURL(selected) + const handleImageSelect = (image: File | string) => { + if (typeof image === "string") { + file = undefined + preview = image + return } + + const reader = new FileReader() + + reader.onload = e => { + file = image + preview = e.target?.result as string + } + + reader.readAsDataURL(image) } @@ -60,9 +65,11 @@ - + diff --git a/src/lib/components/ImageInputButton.svelte b/src/lib/components/ImageInputButton.svelte new file mode 100644 index 00000000..9f8e4aed --- /dev/null +++ b/src/lib/components/ImageInputButton.svelte @@ -0,0 +1,21 @@ + + +