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:
parent
668ed1590c
commit
fd8e93235f
@ -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('')
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user