You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
187 lines
6.6 KiB
187 lines
6.6 KiB
import Uploader from '@/components/PostEditor/Uploader' |
|
import ProfileBanner from '@/components/ProfileBanner' |
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
|
import { Button } from '@/components/ui/button' |
|
import { Input } from '@/components/ui/input' |
|
import { Textarea } from '@/components/ui/textarea' |
|
import { EMAIL_REGEX } from '@/constants' |
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
|
import { createProfileDraftEvent } from '@/lib/draft-event' |
|
import { generateImageByPubkey } from '@/lib/pubkey' |
|
import { useSecondaryPage } from '@/PageManager' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { Loader, Upload } from 'lucide-react' |
|
import { forwardRef, useEffect, useMemo, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { |
|
const { t } = useTranslation() |
|
const { pop } = useSecondaryPage() |
|
const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr() |
|
const [banner, setBanner] = useState<string>('') |
|
const [avatar, setAvatar] = useState<string>('') |
|
const [username, setUsername] = useState<string>('') |
|
const [about, setAbout] = useState<string>('') |
|
const [nip05, setNip05] = useState<string>('') |
|
const [nip05Error, setNip05Error] = useState<string>('') |
|
const [hasChanged, setHasChanged] = useState(false) |
|
const [saving, setSaving] = useState(false) |
|
const [uploadingBanner, setUploadingBanner] = useState(false) |
|
const [uploadingAvatar, setUploadingAvatar] = useState(false) |
|
const defaultImage = useMemo( |
|
() => (account ? generateImageByPubkey(account.pubkey) : undefined), |
|
[account] |
|
) |
|
|
|
useEffect(() => { |
|
if (profile) { |
|
setBanner(profile.banner ?? '') |
|
setAvatar(profile.avatar ?? '') |
|
setUsername(profile.original_username ?? '') |
|
setAbout(profile.about ?? '') |
|
setNip05(profile.nip05 ?? '') |
|
} else { |
|
setBanner('') |
|
setAvatar('') |
|
setUsername('') |
|
setAbout('') |
|
setNip05('') |
|
} |
|
}, [profile]) |
|
|
|
if (!account || !profile) return null |
|
|
|
const save = async () => { |
|
if (nip05 && !EMAIL_REGEX.test(nip05)) { |
|
setNip05Error(t('Invalid NIP-05 address')) |
|
return |
|
} |
|
setSaving(true) |
|
setHasChanged(false) |
|
const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {} |
|
const newProfileContent = { |
|
...oldProfileContent, |
|
display_name: username, |
|
displayName: username, |
|
name: oldProfileContent.name ?? username, |
|
about, |
|
nip05, |
|
banner, |
|
picture: avatar |
|
} |
|
const profileDraftEvent = createProfileDraftEvent( |
|
JSON.stringify(newProfileContent), |
|
profileEvent?.tags |
|
) |
|
const newProfileEvent = await publish(profileDraftEvent) |
|
await updateProfileEvent(newProfileEvent) |
|
setSaving(false) |
|
pop() |
|
} |
|
|
|
const onBannerUploadSuccess = ({ url }: { url: string }) => { |
|
setBanner(url) |
|
setHasChanged(true) |
|
} |
|
|
|
const onAvatarUploadSuccess = ({ url }: { url: string }) => { |
|
setAvatar(url) |
|
setHasChanged(true) |
|
} |
|
|
|
const controls = ( |
|
<div className="pr-3"> |
|
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}> |
|
{saving ? <Loader className="animate-spin" /> : t('Save')} |
|
</Button> |
|
</div> |
|
) |
|
|
|
return ( |
|
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}> |
|
<div className="px-4"> |
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2"> |
|
<Uploader |
|
onUploadSuccess={onBannerUploadSuccess} |
|
onUploadingChange={(uploading) => setTimeout(() => setUploadingBanner(uploading), 50)} |
|
className="w-full relative cursor-pointer" |
|
> |
|
<ProfileBanner |
|
banner={banner} |
|
pubkey={account.pubkey} |
|
className="w-full aspect-video object-cover rounded-lg" |
|
/> |
|
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-lg flex flex-col justify-center items-center"> |
|
{uploadingBanner ? ( |
|
<Loader size={36} className="animate-spin" /> |
|
) : ( |
|
<Upload size={36} /> |
|
)} |
|
</div> |
|
</Uploader> |
|
<Uploader |
|
onUploadSuccess={onAvatarUploadSuccess} |
|
onUploadingChange={(uploading) => setTimeout(() => setUploadingAvatar(uploading), 50)} |
|
className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full" |
|
> |
|
<Avatar className="w-full h-full"> |
|
<AvatarImage src={avatar} className="object-cover object-center" /> |
|
<AvatarFallback> |
|
<img src={defaultImage} /> |
|
</AvatarFallback> |
|
</Avatar> |
|
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center"> |
|
{uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />} |
|
</div> |
|
</Uploader> |
|
</div> |
|
<div className="pt-14 space-y-4"> |
|
<Item> |
|
<ItemTitle>{t('Display Name')}</ItemTitle> |
|
<Input |
|
value={username} |
|
onChange={(e) => { |
|
setUsername(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
/> |
|
</Item> |
|
<Item> |
|
<ItemTitle>{t('Bio')}</ItemTitle> |
|
<Textarea |
|
className="h-44" |
|
value={about} |
|
onChange={(e) => { |
|
setAbout(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
/> |
|
</Item> |
|
<Item> |
|
<ItemTitle>{t('Nostr Address (NIP-05)')}</ItemTitle> |
|
<Input |
|
value={nip05} |
|
onChange={(e) => { |
|
setNip05Error('') |
|
setNip05(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
className={nip05Error ? 'border-destructive' : ''} |
|
/> |
|
{nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>} |
|
</Item> |
|
</div> |
|
</div> |
|
</SecondaryPageLayout> |
|
) |
|
}) |
|
ProfileEditorPage.displayName = 'ProfileEditorPage' |
|
export default ProfileEditorPage |
|
|
|
function ItemTitle({ children }: { children: React.ReactNode }) { |
|
return <div className="text-sm font-semibold text-muted-foreground pl-3">{children}</div> |
|
} |
|
|
|
function Item({ children }: { children: React.ReactNode }) { |
|
return <div className="space-y-1">{children}</div> |
|
}
|
|
|