From fd8e93235fb43d72b3c109f23201e2517b680cc6 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 19 Apr 2026 05:01:57 -0400 Subject: [PATCH] feat(web5): avatar + banner upload on the profile editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the profile editor only accepted external URLs for picture/banner — typing in a URL works, but anyone without their own image host couldn't use an avatar at all. Now there's an "Upload" button next to each field that pushes the selected file to /api/blob and pastes the returned capability-signed local URL (`/blob/?cap=…&exp=…&peer=…`) straight into the form field. - Two new refs: avatarUploading / bannerUploading so each button shows "Uploading…" independently. - uploadAsset(ev, 'picture' | 'banner') wraps the POST, validates HTTP 200 + presence of self_test_url, surfaces failures in the existing profileError banner. - File input is re-cleared on completion so the user can pick the same file again without refreshing. - Live preview in the at the top of the editor updates immediately because profileForm[field] is reactive. Image persists through Save & Publish via the existing identity.update-profile + identity.publish-profile (both now multi-relay). The image URL is still local-only — external nostr clients won't resolve it until we integrate a public image host (noted in task #29). Co-Authored-By: Claude Opus 4.7 (1M context) --- neode-ui/src/views/web5/Web5Identities.vue | 60 ++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue index 30b09c74..9cc79ad6 100644 --- a/neode-ui/src/views/web5/Web5Identities.vue +++ b/neode-ui/src/views/web5/Web5Identities.vue @@ -321,12 +321,24 @@
- - + +
+ + +
- - + +
+ + +
@@ -395,6 +407,46 @@ const profileEditorIdentity = ref(null) const profileForm = ref({}) const profileSaving = ref(false) const profilePublishing = ref(false) +const avatarUploading = ref(false) +const bannerUploading = ref(false) + +// Upload to local blob store + set the corresponding profile URL so +// the kind:0 event (publish) includes a reachable picture/banner. The +// returned `self_test_url` is a capability-signed /blob/?cap=… +// path — works locally. For external nostr clients to see the image, +// swap to a public image host later. +async function uploadAsset(ev: Event, field: 'picture' | 'banner') { + const input = ev.target as HTMLInputElement + const file = input?.files?.[0] + if (!file) return + const flag = field === 'picture' ? avatarUploading : bannerUploading + flag.value = true + profileError.value = '' + try { + const buf = await file.arrayBuffer() + const resp = await fetch('/api/blob', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Blob-Mime': file.type || 'application/octet-stream', + 'X-Blob-Filename': file.name, + }, + body: buf, + }) + if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`) + const { self_test_url } = await resp.json() as { self_test_url?: string } + if (!self_test_url) throw new Error('blob API returned no URL') + // Assign and let the in the header preview react. + profileForm.value[field] = self_test_url + } catch (e: unknown) { + profileError.value = e instanceof Error ? e.message : `${field} upload failed` + } finally { + flag.value = false + // Clear the input so selecting the same file again re-fires change. + if (input) input.value = '' + } +} const profileError = ref('') const profileSuccess = ref('')