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}
+
+ (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 @@
+
+
+