feat: generate new account & profile editor
This commit is contained in:
@@ -46,7 +46,7 @@ export default function FollowingListPage({ id, index }: { id?: string; index?:
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
index={index}
|
||||
titlebarContent={
|
||||
title={
|
||||
profile?.username
|
||||
? t("username's following", { username: profile.username })
|
||||
: t('Following')
|
||||
|
||||
@@ -2,7 +2,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
|
||||
export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||
<SecondaryPageLayout index={index} title={title}>
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function NoteListPage({ index }: { index?: number }) {
|
||||
}, [searchParams, relayUrlsString])
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
|
||||
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
|
||||
if (!event && isFetching) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')}>
|
||||
<SecondaryPageLayout index={index} title={t('Note')}>
|
||||
<div className="px-4">
|
||||
<Skeleton className="w-10 h-10 rounded-full" />
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
|
||||
if (isPictureEvent(event) && isSmallScreen) {
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
|
||||
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||
<Separator className="mb-2 mt-4" />
|
||||
<Nip22ReplyNoteList
|
||||
@@ -50,7 +50,7 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={t('Note')} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
{rootEventId !== parentEventId && (
|
||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||
|
||||
185
src/pages/secondary/ProfileEditorPage/index.tsx
Normal file
185
src/pages/secondary/ProfileEditorPage/index.tsx
Normal file
@@ -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>
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export default function ProfileListPage({ index }: { index?: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={title} displayScrollToTopButton>
|
||||
<div className="space-y-2 px-4">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
|
||||
@@ -6,13 +6,14 @@ import ProfileBanner from '@/components/ProfileBanner'
|
||||
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||
import QrCodePopover from '@/components/QrCodePopover'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toFollowingList } from '@/lib/link'
|
||||
import { toFollowingList, toProfileEditor } from '@/lib/link'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
@@ -22,6 +23,7 @@ import NotFoundPage from '../NotFoundPage'
|
||||
|
||||
export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { profile, isFetching } = useFetchProfile(id)
|
||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||
const { relayUrls: currentRelayUrls } = useFeed()
|
||||
@@ -64,7 +66,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
|
||||
const { banner, username, nip05, about, avatar, pubkey } = profile
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton>
|
||||
<SecondaryPageLayout index={index} title={username} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<ProfileBanner
|
||||
@@ -85,7 +87,17 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
|
||||
{t('Follows you')}
|
||||
</div>
|
||||
)}
|
||||
<FollowButton pubkey={pubkey} />
|
||||
{isSelf ? (
|
||||
<Button
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => push(toProfileEditor())}
|
||||
>
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<FollowButton pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function RelaySettingsPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
||||
<SecondaryPageLayout index={index} title={t('Relay settings')}>
|
||||
<Tabs defaultValue="relay-sets" className="px-4 space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="relay-sets">{t('Relay Sets')}</TabsTrigger>
|
||||
|
||||
@@ -4,18 +4,21 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toRelaySettings } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { TLanguage } from '@/types'
|
||||
import { SelectValue } from '@radix-ui/react-select'
|
||||
import { ChevronRight, Info, Languages, Server, SunMoon } from 'lucide-react'
|
||||
import { Check, ChevronRight, Copy, Info, KeyRound, Languages, Server, SunMoon } from 'lucide-react'
|
||||
import { forwardRef, HTMLProps, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SettingsPage({ index }: { index?: number }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { nsec } = useNostr()
|
||||
const { push } = useSecondaryPage()
|
||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||
|
||||
const handleLanguageChange = (value: TLanguage) => {
|
||||
i18n.changeLanguage(value)
|
||||
@@ -23,7 +26,7 @@ export default function SettingsPage({ index }: { index?: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}>
|
||||
<SecondaryPageLayout index={index} title={t('Settings')}>
|
||||
<SettingItem>
|
||||
<div className="flex items-center gap-4">
|
||||
<Languages />
|
||||
@@ -62,6 +65,21 @@ export default function SettingsPage({ index }: { index?: number }) {
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
{!!nsec && (
|
||||
<SettingItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopiedNsec(true)
|
||||
setTimeout(() => setCopiedNsec(false), 2000)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<KeyRound />
|
||||
<div>{t('Copy private key (nsec)')}</div>
|
||||
</div>
|
||||
{copiedNsec ? <Check /> : <Copy />}
|
||||
</SettingItem>
|
||||
)}
|
||||
<AboutInfoDialog>
|
||||
<SettingItem>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
Reference in New Issue
Block a user