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

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>
}