feat(nostr): profile editor with kind:0 publishing (M11.4)
Edit display name, bio, avatar, banner, website, NIP-05, Lightning address. Live profile card preview. Publish as kind:0 event via NIP-07. Added Profile sub-tab in Nostr section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ba6b22bb62
commit
bad7506b72
@@ -21,6 +21,9 @@
|
||||
<!-- Relays sub-tab -->
|
||||
<NostrRelayManager v-else-if="activeSubTab === 'relays'" />
|
||||
|
||||
<!-- Profile sub-tab -->
|
||||
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
|
||||
|
||||
<!-- Feed sub-tab -->
|
||||
<template v-else>
|
||||
<div class="p-4 space-y-3 border-b border-white/[0.08]">
|
||||
@@ -195,6 +198,7 @@
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import NostrDMs from './NostrDMs.vue'
|
||||
import NostrRelayManager from './NostrRelayManager.vue'
|
||||
import NostrProfileEditor from './NostrProfileEditor.vue'
|
||||
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
@@ -203,11 +207,12 @@ defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
const { events: notes, isConnected, relayStates, connect, publishEvent } = useNostr()
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
|
||||
const activeSubTab = ref<'feed' | 'dms' | 'relays'>('feed')
|
||||
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile'>('feed')
|
||||
const subTabs = [
|
||||
{ id: 'feed' as const, label: 'Feed' },
|
||||
{ id: 'dms' as const, label: 'Messages' },
|
||||
{ id: 'relays' as const, label: 'Relays' },
|
||||
{ id: 'profile' as const, label: 'Profile' },
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 border-b border-white/[0.08]">
|
||||
<h3 class="text-sm font-bold text-white/90">Nostr Profile</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLoggedIn" class="flex-1 flex items-center justify-center">
|
||||
<p class="text-xs text-white/30">Sign in with Nostr to edit your profile</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-4">
|
||||
<!-- Profile card preview -->
|
||||
<div class="rounded-xl overflow-hidden border border-white/5">
|
||||
<div
|
||||
class="h-24 bg-cover bg-center"
|
||||
:style="profile.banner ? { backgroundImage: `url(${profile.banner})` } : {}"
|
||||
:class="!profile.banner ? 'bg-gradient-to-r from-accent/20 to-purple-500/20' : ''"
|
||||
/>
|
||||
<div class="px-4 pb-4 -mt-8">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full border-2 border-black bg-cover bg-center flex items-center justify-center"
|
||||
:style="profile.picture ? { backgroundImage: `url(${profile.picture})` } : {}"
|
||||
:class="!profile.picture ? 'bg-accent/20' : ''"
|
||||
>
|
||||
<span v-if="!profile.picture" class="text-lg font-bold text-accent">
|
||||
{{ (profile.display_name || profile.name || '?').charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-white/90 mt-2">{{ profile.display_name || profile.name || 'Anonymous' }}</p>
|
||||
<p v-if="profile.nip05" class="text-[10px] text-purple-400/60">{{ profile.nip05 }}</p>
|
||||
<p v-if="profile.about" class="text-xs text-white/50 mt-1 line-clamp-2">{{ profile.about }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit form -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Display Name</label>
|
||||
<input
|
||||
v-model="profile.display_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Username</label>
|
||||
<input
|
||||
v-model="profile.name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Bio</label>
|
||||
<textarea
|
||||
v-model="profile.about"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors resize-none min-h-[60px]"
|
||||
placeholder="Tell the world about yourself"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Avatar URL</label>
|
||||
<input
|
||||
v-model="profile.picture"
|
||||
type="url"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Banner URL</label>
|
||||
<input
|
||||
v-model="profile.banner"
|
||||
type="url"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Website</label>
|
||||
<input
|
||||
v-model="profile.website"
|
||||
type="url"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">NIP-05 Address</label>
|
||||
<input
|
||||
v-model="profile.nip05"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-white/30 block mb-1">Lightning Address</label>
|
||||
<input
|
||||
v-model="profile.lud16"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
placeholder="you@getalby.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Publish button -->
|
||||
<button
|
||||
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="isPublishing"
|
||||
@click="publishProfile"
|
||||
>
|
||||
{{ isPublishing ? 'Publishing...' : 'Publish Profile (kind:0)' }}
|
||||
</button>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="publishStatus" class="text-[10px] text-center" :class="publishSuccess ? 'text-emerald-400/60' : 'text-red-400/60'">
|
||||
{{ publishStatus }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
|
||||
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
|
||||
const { publishEvent, fetchNote } = useNostr()
|
||||
|
||||
interface ProfileData {
|
||||
name: string
|
||||
display_name: string
|
||||
about: string
|
||||
picture: string
|
||||
banner: string
|
||||
website: string
|
||||
nip05: string
|
||||
lud16: string
|
||||
}
|
||||
|
||||
const profile = reactive<ProfileData>({
|
||||
name: '',
|
||||
display_name: '',
|
||||
about: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
website: '',
|
||||
nip05: '',
|
||||
lud16: '',
|
||||
})
|
||||
|
||||
const isPublishing = ref(false)
|
||||
const publishStatus = ref('')
|
||||
const publishSuccess = ref(false)
|
||||
|
||||
async function loadExistingProfile() {
|
||||
if (!pubkey.value) return
|
||||
const note = await fetchNote(pubkey.value, 5000)
|
||||
if (note && note.kind === 0) {
|
||||
try {
|
||||
const data = JSON.parse(note.content) as Partial<ProfileData>
|
||||
Object.assign(profile, data)
|
||||
} catch { /* invalid JSON */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function publishProfile() {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
isPublishing.value = true
|
||||
publishStatus.value = ''
|
||||
|
||||
const content: Record<string, string> = {}
|
||||
for (const [key, val] of Object.entries(profile)) {
|
||||
if (val) content[key] = val
|
||||
}
|
||||
|
||||
const unsigned = {
|
||||
kind: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
}
|
||||
|
||||
const signed = await signEvent(unsigned)
|
||||
if (!signed) {
|
||||
isPublishing.value = false
|
||||
publishStatus.value = 'Signing failed'
|
||||
publishSuccess.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const results = await publishEvent(signed)
|
||||
const successes = results.filter(r => r.success).length
|
||||
isPublishing.value = false
|
||||
|
||||
if (successes > 0) {
|
||||
publishStatus.value = `Published to ${successes}/${results.length} relays`
|
||||
publishSuccess.value = true
|
||||
} else {
|
||||
publishStatus.value = 'Failed to publish to any relay'
|
||||
publishSuccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadExistingProfile()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user