index.tsx raw
1 import Uploader from '@/components/PostEditor/Uploader'
2 import ProfileBanner from '@/components/ProfileBanner'
3 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
4 import { Button } from '@/components/ui/button'
5 import { Input } from '@/components/ui/input'
6 import { Label } from '@/components/ui/label'
7 import { Textarea } from '@/components/ui/textarea'
8 import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
9 import { createProfileDraftEvent } from '@/lib/draft-event'
10 import { generateImageByPubkey } from '@/lib/pubkey'
11 import { isEmail } from '@/lib/utils'
12 import { useSecondaryPage } from '@/PageManager'
13 import { useNostr } from '@/providers/NostrProvider'
14 import { Loader, Upload } from 'lucide-react'
15 import { forwardRef, useEffect, useMemo, useState } from 'react'
16 import { useTranslation } from 'react-i18next'
17
18 const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
19 const { t } = useTranslation()
20 const { pop } = useSecondaryPage()
21 const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr()
22 const [banner, setBanner] = useState<string>('')
23 const [avatar, setAvatar] = useState<string>('')
24 const [username, setUsername] = useState<string>('')
25 const [about, setAbout] = useState<string>('')
26 const [website, setWebsite] = useState<string>('')
27 const [nip05, setNip05] = useState<string>('')
28 const [nip05Error, setNip05Error] = useState<string>('')
29 const [lightningAddress, setLightningAddress] = useState<string>('')
30 const [lightningAddressError, setLightningAddressError] = useState<string>('')
31 const [hasChanged, setHasChanged] = useState(false)
32 const [saving, setSaving] = useState(false)
33 const [uploadingBanner, setUploadingBanner] = useState(false)
34 const [uploadingAvatar, setUploadingAvatar] = useState(false)
35 const defaultImage = useMemo(
36 () => (account ? generateImageByPubkey(account.pubkey) : undefined),
37 [account]
38 )
39
40 useEffect(() => {
41 if (profile) {
42 setBanner(profile.banner ?? '')
43 setAvatar(profile.avatar ?? '')
44 setUsername(profile.original_username ?? '')
45 setAbout(profile.about ?? '')
46 setWebsite(profile.website ?? '')
47 setNip05(profile.nip05 ?? '')
48 setLightningAddress(profile.lightningAddress || '')
49 } else {
50 setBanner('')
51 setAvatar('')
52 setUsername('')
53 setAbout('')
54 setWebsite('')
55 setNip05('')
56 setLightningAddress('')
57 }
58 }, [profile])
59
60 if (!account || !profile) return null
61
62 const save = async () => {
63 if (nip05 && !isEmail(nip05)) {
64 setNip05Error(t('Invalid NIP-05 address'))
65 return
66 }
67
68 const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {}
69 const newProfileContent = {
70 ...oldProfileContent,
71 display_name: username,
72 displayName: username,
73 name: oldProfileContent.name ?? username,
74 about,
75 website,
76 nip05,
77 banner,
78 picture: avatar
79 }
80
81 if (lightningAddress) {
82 if (isEmail(lightningAddress)) {
83 newProfileContent.lud16 = lightningAddress
84 } else if (lightningAddress.startsWith('lnurl')) {
85 newProfileContent.lud06 = lightningAddress
86 } else {
87 setLightningAddressError(t('Invalid Lightning Address'))
88 return
89 }
90 } else {
91 delete newProfileContent.lud16
92 }
93
94 setSaving(true)
95 setHasChanged(false)
96 const profileDraftEvent = createProfileDraftEvent(
97 JSON.stringify(newProfileContent),
98 profileEvent?.tags
99 )
100 const newProfileEvent = await publish(profileDraftEvent)
101 await updateProfileEvent(newProfileEvent)
102 setSaving(false)
103 pop()
104 }
105
106 const onBannerUploadSuccess = ({ url }: { url: string }) => {
107 setBanner(url)
108 setHasChanged(true)
109 }
110
111 const onAvatarUploadSuccess = ({ url }: { url: string }) => {
112 setAvatar(url)
113 setHasChanged(true)
114 }
115
116 const controls = (
117 <div className="pr-3">
118 <Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}>
119 {saving ? <Loader className="animate-spin" /> : t('Save')}
120 </Button>
121 </div>
122 )
123
124 return (
125 <SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}>
126 <div className="relative bg-cover bg-center mb-2">
127 <Uploader
128 onUploadSuccess={onBannerUploadSuccess}
129 onUploadStart={() => setUploadingBanner(true)}
130 onUploadEnd={() => setUploadingBanner(false)}
131 className="w-full relative cursor-pointer"
132 >
133 <ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
134 <div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
135 {uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />}
136 </div>
137 </Uploader>
138 <Uploader
139 onUploadSuccess={onAvatarUploadSuccess}
140 onUploadStart={() => setUploadingAvatar(true)}
141 onUploadEnd={() => setUploadingAvatar(false)}
142 className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
143 >
144 <Avatar className="w-full h-full">
145 <AvatarImage src={avatar} className="object-cover object-center" />
146 <AvatarFallback>
147 <img src={defaultImage} />
148 </AvatarFallback>
149 </Avatar>
150 <div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center">
151 {uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />}
152 </div>
153 </Uploader>
154 </div>
155 <div className="pt-14 px-4 flex flex-col gap-4">
156 <Item>
157 <Label htmlFor="profile-username-input">{t('Display Name')}</Label>
158 <Input
159 id="profile-username-input"
160 value={username}
161 onChange={(e) => {
162 setUsername(e.target.value)
163 setHasChanged(true)
164 }}
165 />
166 </Item>
167 <Item>
168 <Label htmlFor="profile-about-textarea">{t('Bio')}</Label>
169 <Textarea
170 id="profile-about-textarea"
171 className="h-44"
172 value={about}
173 onChange={(e) => {
174 setAbout(e.target.value)
175 setHasChanged(true)
176 }}
177 />
178 </Item>
179 <Item>
180 <Label htmlFor="profile-website-input">{t('Website')}</Label>
181 <Input
182 id="profile-website-input"
183 value={website}
184 onChange={(e) => {
185 setWebsite(e.target.value)
186 setHasChanged(true)
187 }}
188 />
189 </Item>
190 <Item>
191 <Label htmlFor="profile-nip05-input">{t('Nostr Address (NIP-05)')}</Label>
192 <Input
193 id="profile-nip05-input"
194 value={nip05}
195 onChange={(e) => {
196 setNip05Error('')
197 setNip05(e.target.value)
198 setHasChanged(true)
199 }}
200 className={nip05Error ? 'border-destructive' : ''}
201 />
202 {nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>}
203 </Item>
204 <Item>
205 <Label htmlFor="profile-lightning-address-input">
206 {t('Lightning Address (or LNURL)')}
207 </Label>
208 <Input
209 id="profile-lightning-address-input"
210 value={lightningAddress}
211 onChange={(e) => {
212 setLightningAddressError('')
213 setLightningAddress(e.target.value)
214 setHasChanged(true)
215 }}
216 className={lightningAddressError ? 'border-destructive' : ''}
217 />
218 {lightningAddressError && (
219 <div className="text-xs text-destructive pl-3">{lightningAddressError}</div>
220 )}
221 </Item>
222 </div>
223 </SecondaryPageLayout>
224 )
225 })
226 ProfileEditorPage.displayName = 'ProfileEditorPage'
227 export default ProfileEditorPage
228
229 function Item({ children }: { children: React.ReactNode }) {
230 return <div className="grid gap-2">{children}</div>
231 }
232