feat(web5): avatar + banner upload on the profile editor

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/<cid>?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 <img> 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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-19 05:01:57 -04:00
parent 668ed1590c
commit fd8e93235f

View File

@ -321,12 +321,24 @@
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
<label class="text-white/60 text-xs block mb-1">Profile Picture</label>
<div class="flex gap-2">
<input v-model="profileForm.picture" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
{{ avatarUploading ? 'Uploading…' : 'Upload' }}
<input type="file" accept="image/*" class="hidden" :disabled="avatarUploading" @change="uploadAsset($event, 'picture')" />
</label>
</div>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
<label class="text-white/60 text-xs block mb-1">Banner Image</label>
<div class="flex gap-2">
<input v-model="profileForm.banner" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
{{ bannerUploading ? 'Uploading…' : 'Upload' }}
<input type="file" accept="image/*" class="hidden" :disabled="bannerUploading" @change="uploadAsset($event, 'banner')" />
</label>
</div>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Website</label>
@ -395,6 +407,46 @@ const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
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/<cid>?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 <img> 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('')