feat: multi accounts
This commit is contained in:
parent
ee0c702135
commit
33ac5e60b6
17 changed files with 426 additions and 137 deletions
|
|
@ -11,19 +11,22 @@ import { toProfile } from '@/lib/link'
|
|||
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoginDialog from '../LoginDialog'
|
||||
|
||||
export default function ProfileButton({
|
||||
pubkey,
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
pubkey: string
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { logout } = useNostr()
|
||||
const { removeAccount, account } = useNostr()
|
||||
const pubkey = account?.pubkey
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { push } = useSecondaryPage()
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||
if (!pubkey) return null
|
||||
|
||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
||||
|
|
@ -72,10 +75,17 @@ export default function ProfileButton({
|
|||
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
|
||||
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||
{t('Manage accounts')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => removeAccount(account)}
|
||||
>
|
||||
{t('Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default function AccountButton({
|
|||
const { pubkey } = useNostr()
|
||||
|
||||
if (pubkey) {
|
||||
return <ProfileButton variant={variant} pubkey={pubkey} />
|
||||
return <ProfileButton variant={variant} />
|
||||
} else {
|
||||
return <LoginButton variant={variant} />
|
||||
}
|
||||
|
|
|
|||
76
src/components/AccountList/index.tsx
Normal file
76
src/components/AccountList/index.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { isSameAccount } from '@/lib/account'
|
||||
import { formatPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TAccountPointer, TSignerType } from '@/types'
|
||||
import { Loader, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
|
||||
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
||||
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((act) => (
|
||||
<div
|
||||
key={`${act.pubkey}-${act.signerType}`}
|
||||
className={cn(
|
||||
'relative rounded-lg',
|
||||
isSameAccount(act, account)
|
||||
? 'border border-primary'
|
||||
: 'cursor-pointer hover:bg-muted/60'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isSameAccount(act, account)) return
|
||||
setSwitchingAccount(act)
|
||||
switchAccount(act)
|
||||
.then(() => afterSwitch())
|
||||
.finally(() => setSwitchingAccount(null))
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center p-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<SimpleUserAvatar userId={act.pubkey} />
|
||||
<div>
|
||||
<SimpleUsername userId={act.pubkey} className="font-semibold" />
|
||||
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
||||
{formatPubkey(act.pubkey)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<SignerTypeBadge signerType={act.signerType} />
|
||||
<Trash2
|
||||
size={16}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeAccount(act)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
|
||||
<Loader size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
|
||||
if (signerType === 'nip-07') {
|
||||
return <Badge className=" bg-green-400 hover:bg-green-400/80">NIP-07</Badge>
|
||||
} else if (signerType === 'bunker') {
|
||||
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
|
||||
} else {
|
||||
return <Badge>NSEC</Badge>
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,13 @@ import { Loader } from 'lucide-react'
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
||||
export default function BunkerLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { bunkerLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
|
|
@ -42,6 +48,9 @@ export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () =>
|
|||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,7 +4,13 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
||||
export default function PrivateKeyLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsec, setNsec] = useState('')
|
||||
|
|
@ -43,6 +49,9 @@ export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: ()
|
|||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
src/components/AccountManager/index.tsx
Normal file
64
src/components/AccountManager/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TSignerType } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
|
||||
export default function AccountManager({ close }: { close: () => void }) {
|
||||
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
) : (
|
||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountManagerNav({
|
||||
setLoginMethod,
|
||||
close
|
||||
}: {
|
||||
setLoginMethod: (method: TSignerType) => void
|
||||
close: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => close())} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Logged in Accounts')}
|
||||
</div>
|
||||
<AccountList afterSwitch={() => close()} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -6,12 +5,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Dispatch, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
import { Dispatch } from 'react'
|
||||
import AccountManager from '../AccountManager'
|
||||
|
||||
export default function LoginDialog({
|
||||
open,
|
||||
|
|
@ -20,10 +15,6 @@ export default function LoginDialog({
|
|||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
|
||||
const { nip07Login } = useNostr()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96">
|
||||
|
|
@ -31,41 +22,7 @@ export default function LoginDialog({
|
|||
<DialogTitle className="hidden" />
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||
onClick={() => setLoginMethod(null)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||
onClick={() => setLoginMethod(null)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<AccountManager close={() => setOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,3 +54,35 @@ export default function UserAvatar({
|
|||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimpleUserAvatar({
|
||||
userId,
|
||||
size = 'normal',
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
userId: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
|
||||
[profile]
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
return (
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)} onClick={onClick}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={pubkey} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,3 +42,27 @@ export default function Username({
|
|||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimpleUsername({
|
||||
userId,
|
||||
showAt = false,
|
||||
className,
|
||||
skeletonClassName
|
||||
}: {
|
||||
userId: string
|
||||
showAt?: boolean
|
||||
className?: string
|
||||
skeletonClassName?: string
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
|
||||
|
||||
const { username } = profile
|
||||
|
||||
return (
|
||||
<div className={cn('max-w-fit', className)}>
|
||||
{showAt && '@'}
|
||||
{username}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
Loading…
Add table
Add a link
Reference in a new issue