33 changed files with 535 additions and 142 deletions
@ -0,0 +1,59 @@
@@ -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 @@
@@ -1,5 +0,0 @@
|
||||
export type TMailboxRelayScope = 'read' | 'write' | 'both' |
||||
export type TMailboxRelay = { |
||||
url: string |
||||
scope: TMailboxRelayScope |
||||
} |
||||
@ -0,0 +1,185 @@
@@ -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