33 changed files with 535 additions and 142 deletions
@ -0,0 +1,59 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Input } from '@/components/ui/input' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Check, Copy, RefreshCcw } from 'lucide-react' |
||||||
|
import { generateSecretKey } from 'nostr-tools' |
||||||
|
import { nsecEncode } from 'nostr-tools/nip19' |
||||||
|
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function GenerateNewAccount({ |
||||||
|
back, |
||||||
|
onLoginSuccess |
||||||
|
}: { |
||||||
|
back: () => void |
||||||
|
onLoginSuccess: () => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { nsecLogin } = useNostr() |
||||||
|
const [nsec, setNsec] = useState(generateNsec()) |
||||||
|
const [copied, setCopied] = useState(false) |
||||||
|
|
||||||
|
const handleLogin = () => { |
||||||
|
nsecLogin(nsec).then(() => onLoginSuccess()) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className="text-orange-400"> |
||||||
|
{t( |
||||||
|
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.' |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className="flex gap-2"> |
||||||
|
<Input value={nsec} /> |
||||||
|
<Button variant="secondary" onClick={() => setNsec(generateNsec())}> |
||||||
|
<RefreshCcw /> |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
onClick={() => { |
||||||
|
navigator.clipboard.writeText(nsec) |
||||||
|
setCopied(true) |
||||||
|
setTimeout(() => setCopied(false), 2000) |
||||||
|
}} |
||||||
|
> |
||||||
|
{copied ? <Check /> : <Copy />} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
<Button onClick={handleLogin}>{t('Login')}</Button> |
||||||
|
<Button variant="secondary" onClick={back}> |
||||||
|
{t('Back')} |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function generateNsec() { |
||||||
|
const sk = generateSecretKey() |
||||||
|
return nsecEncode(sk) |
||||||
|
} |
||||||
@ -1,5 +0,0 @@ |
|||||||
export type TMailboxRelayScope = 'read' | 'write' | 'both' |
|
||||||
export type TMailboxRelay = { |
|
||||||
url: string |
|
||||||
scope: TMailboxRelayScope |
|
||||||
} |
|
||||||
@ -0,0 +1,185 @@ |
|||||||
|
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 { useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function ProfileEditorPage({ index }: { index?: number }) { |
||||||
|
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) |
||||||
|
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 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> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
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> |
||||||
|
} |
||||||
Loading…
Reference in new issue