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>
|
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Profile Picture URL</label>
|
<label class="text-white/60 text-xs block mb-1">Profile Picture</label>
|
||||||
<input v-model="profileForm.picture" type="url" placeholder="https://..." class="w-full input-glass" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Banner Image URL</label>
|
<label class="text-white/60 text-xs block mb-1">Banner Image</label>
|
||||||
<input v-model="profileForm.banner" type="url" placeholder="https://..." class="w-full input-glass" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-white/60 text-xs block mb-1">Website</label>
|
<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 profileForm = ref<IdentityProfile>({})
|
||||||
const profileSaving = ref(false)
|
const profileSaving = ref(false)
|
||||||
const profilePublishing = 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 profileError = ref('')
|
||||||
const profileSuccess = ref('')
|
const profileSuccess = ref('')
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user