98 changed files with 2503 additions and 1053 deletions
@ -0,0 +1,24 @@ |
|||||||
|
export default function Icon({ className }: { className?: string }) { |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
viewBox="0 0 1080 1228" |
||||||
|
version="1.1" |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink" |
||||||
|
xmlSpace="preserve" |
||||||
|
style={{ |
||||||
|
fill: 'currentcolor', |
||||||
|
fillRule: 'evenodd', |
||||||
|
clipRule: 'evenodd', |
||||||
|
strokeLinejoin: 'round', |
||||||
|
strokeMiterlimit: 2 |
||||||
|
}} |
||||||
|
className={className} |
||||||
|
> |
||||||
|
<path |
||||||
|
id="Icon-Curve-Cut" |
||||||
|
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
) |
||||||
|
} |
||||||
File diff suppressed because one or more lines are too long
@ -1,29 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { LogIn } from 'lucide-react' |
|
||||||
|
|
||||||
export default function LoginButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { checkLogin } = useNostr() |
|
||||||
|
|
||||||
let triggerComponent: React.ReactNode |
|
||||||
if (variant === 'titlebar' || variant === 'small-screen-titlebar') { |
|
||||||
triggerComponent = <LogIn /> |
|
||||||
} else { |
|
||||||
triggerComponent = ( |
|
||||||
<> |
|
||||||
<LogIn size={16} /> |
|
||||||
<div>Login</div> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Button variant={variant} size={variant} onClick={() => checkLogin()}> |
|
||||||
{triggerComponent} |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,91 +0,0 @@ |
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
DropdownMenu, |
|
||||||
DropdownMenuContent, |
|
||||||
DropdownMenuItem, |
|
||||||
DropdownMenuTrigger |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { useFetchProfile } from '@/hooks' |
|
||||||
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({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
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 } |
|
||||||
|
|
||||||
let triggerComponent: React.ReactNode |
|
||||||
if (variant === 'titlebar') { |
|
||||||
triggerComponent = ( |
|
||||||
<button> |
|
||||||
<Avatar className="ml-2 w-6 h-6 hover:opacity-90"> |
|
||||||
<AvatarImage src={avatar} /> |
|
||||||
<AvatarFallback> |
|
||||||
<img src={defaultAvatar} /> |
|
||||||
</AvatarFallback> |
|
||||||
</Avatar> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} else if (variant === 'small-screen-titlebar') { |
|
||||||
triggerComponent = ( |
|
||||||
<button> |
|
||||||
<Avatar className="w-8 h-8 hover:opacity-90"> |
|
||||||
<AvatarImage src={avatar} /> |
|
||||||
<AvatarFallback> |
|
||||||
<img src={defaultAvatar} /> |
|
||||||
</AvatarFallback> |
|
||||||
</Avatar> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} else { |
|
||||||
triggerComponent = ( |
|
||||||
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2"> |
|
||||||
<div className="flex gap-2 items-center flex-1 w-0"> |
|
||||||
<Avatar className="w-10 h-10"> |
|
||||||
<AvatarImage src={avatar} /> |
|
||||||
<AvatarFallback> |
|
||||||
<img src={defaultAvatar} /> |
|
||||||
</AvatarFallback> |
|
||||||
</Avatar> |
|
||||||
<div className="truncate font-semibold text-lg">{username}</div> |
|
||||||
</div> |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent> |
|
||||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem> |
|
||||||
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}> |
|
||||||
{t('Accounts')} |
|
||||||
</DropdownMenuItem> |
|
||||||
<DropdownMenuItem |
|
||||||
className="text-destructive focus:text-destructive" |
|
||||||
onClick={() => removeAccount(account)} |
|
||||||
> |
|
||||||
{t('Logout')} |
|
||||||
</DropdownMenuItem> |
|
||||||
</DropdownMenuContent> |
|
||||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import LoginButton from './LoginButton' |
|
||||||
import ProfileButton from './ProfileButton' |
|
||||||
|
|
||||||
export default function AccountButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { pubkey } = useNostr() |
|
||||||
|
|
||||||
if (pubkey) { |
|
||||||
return <ProfileButton variant={variant} /> |
|
||||||
} else { |
|
||||||
return <LoginButton variant={variant} /> |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { UserRound } from 'lucide-react' |
||||||
|
import { SimpleUserAvatar } from '../UserAvatar' |
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
|
export default function AccountButton() { |
||||||
|
const { navigate, current } = usePrimaryPage() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
|
||||||
|
return ( |
||||||
|
<BottomNavigationBarItem |
||||||
|
onClick={() => { |
||||||
|
navigate('me') |
||||||
|
}} |
||||||
|
active={current === 'me'} |
||||||
|
> |
||||||
|
{pubkey ? ( |
||||||
|
<SimpleUserAvatar |
||||||
|
userId={pubkey} |
||||||
|
size="small" |
||||||
|
className={current === 'me' ? 'ring-primary ring-1' : ''} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<UserRound /> |
||||||
|
)} |
||||||
|
</BottomNavigationBarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Button } from '../ui/button' |
||||||
|
import { MouseEventHandler } from 'react' |
||||||
|
|
||||||
|
export default function BottomNavigationBarItem({ |
||||||
|
children, |
||||||
|
active = false, |
||||||
|
onClick |
||||||
|
}: { |
||||||
|
children: React.ReactNode |
||||||
|
active?: boolean |
||||||
|
onClick: MouseEventHandler |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<Button |
||||||
|
className={cn( |
||||||
|
'flex shadow-none items-center bg-transparent w-full h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4', |
||||||
|
active && 'text-primary disabled:opacity-100' |
||||||
|
)} |
||||||
|
disabled={active} |
||||||
|
variant="ghost" |
||||||
|
onClick={onClick} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Home } from 'lucide-react' |
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
|
export default function HomeButton() { |
||||||
|
const { navigate, current } = usePrimaryPage() |
||||||
|
|
||||||
|
return ( |
||||||
|
<BottomNavigationBarItem active={current === 'home'} onClick={() => navigate('home')}> |
||||||
|
<Home /> |
||||||
|
</BottomNavigationBarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Bell } from 'lucide-react' |
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
|
export default function NotificationsButton() { |
||||||
|
const { navigate, current } = usePrimaryPage() |
||||||
|
|
||||||
|
return ( |
||||||
|
<BottomNavigationBarItem |
||||||
|
active={current === 'notifications'} |
||||||
|
onClick={() => navigate('notifications')} |
||||||
|
> |
||||||
|
<Bell /> |
||||||
|
</BottomNavigationBarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
import PostEditor from '@/components/PostEditor' |
||||||
|
import { PencilLine } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem' |
||||||
|
|
||||||
|
export default function PostButton() { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<BottomNavigationBarItem |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
setOpen(true) |
||||||
|
}} |
||||||
|
> |
||||||
|
<PencilLine /> |
||||||
|
</BottomNavigationBarItem> |
||||||
|
<PostEditor open={open} setOpen={setOpen} /> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import HomeButton from './HomeButton' |
||||||
|
import NotificationsButton from './NotificationsButton' |
||||||
|
import PostButton from './PostButton' |
||||||
|
import AccountButton from './AccountButton' |
||||||
|
|
||||||
|
export default function BottomNavigationBar({ visible = true }: { visible?: boolean }) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'fixed bottom-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0', |
||||||
|
visible ? '' : 'translate-y-full' |
||||||
|
)} |
||||||
|
style={{ |
||||||
|
height: 'calc(3rem + env(safe-area-inset-bottom))', |
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)' |
||||||
|
}} |
||||||
|
> |
||||||
|
<HomeButton /> |
||||||
|
<PostButton /> |
||||||
|
<NotificationsButton /> |
||||||
|
<AccountButton /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
import { toRelaySettings } from '@/lib/link' |
||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
import { SecondaryPageLink } from '@/PageManager' |
||||||
|
import { useFeed } from '@/providers/FeedProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
||||||
|
import { Circle, CircleCheck } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function FeedSwitcher({ close }: { close?: () => void }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { feedType, setFeedType } = useFeed() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const { relayGroups, temporaryRelayUrls, switchRelayGroup } = useRelaySettings() |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-4"> |
||||||
|
{pubkey && ( |
||||||
|
<FeedSwitcherItem |
||||||
|
itemName={t('Following')} |
||||||
|
isActive={feedType === 'following'} |
||||||
|
onClick={() => { |
||||||
|
setFeedType('following') |
||||||
|
close?.() |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="flex justify-between px-2"> |
||||||
|
<div className="text-muted-foreground text-sm font-semibold">{t('relay feeds')}</div> |
||||||
|
<SecondaryPageLink |
||||||
|
to={toRelaySettings()} |
||||||
|
className="text-highlight text-sm font-semibold" |
||||||
|
onClick={() => close?.()} |
||||||
|
> |
||||||
|
{t('edit')} |
||||||
|
</SecondaryPageLink> |
||||||
|
</div> |
||||||
|
{temporaryRelayUrls.length > 0 && ( |
||||||
|
<FeedSwitcherItem |
||||||
|
key="temporary" |
||||||
|
itemName={ |
||||||
|
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary') |
||||||
|
} |
||||||
|
isActive={feedType === 'relays'} |
||||||
|
temporary |
||||||
|
onClick={() => { |
||||||
|
setFeedType('relays') |
||||||
|
close?.() |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{relayGroups |
||||||
|
.filter((group) => group.relayUrls.length > 0) |
||||||
|
.map((group) => ( |
||||||
|
<FeedSwitcherItem |
||||||
|
key={group.groupName} |
||||||
|
itemName={ |
||||||
|
group.relayUrls.length === 1 ? simplifyUrl(group.relayUrls[0]) : group.groupName |
||||||
|
} |
||||||
|
isActive={feedType === 'relays' && group.isActive && temporaryRelayUrls.length === 0} |
||||||
|
onClick={() => { |
||||||
|
switchRelayGroup(group.groupName) |
||||||
|
close?.() |
||||||
|
}} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FeedSwitcherItem({ |
||||||
|
itemName, |
||||||
|
isActive, |
||||||
|
temporary = false, |
||||||
|
onClick |
||||||
|
}: { |
||||||
|
itemName: string |
||||||
|
isActive: boolean |
||||||
|
temporary?: boolean |
||||||
|
onClick: () => void |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : 'clickable'} ${temporary ? 'border-dashed' : ''}`} |
||||||
|
onClick={onClick} |
||||||
|
> |
||||||
|
<div className="flex space-x-2 items-center"> |
||||||
|
<FeedToggle isActive={isActive} /> |
||||||
|
<div className="font-semibold">{itemName}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function FeedToggle({ isActive }: { isActive: boolean }) { |
||||||
|
return isActive ? ( |
||||||
|
<CircleCheck size={18} className="text-highlight shrink-0" /> |
||||||
|
) : ( |
||||||
|
<Circle size={18} className="text-muted-foreground shrink-0" /> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
import { |
||||||
|
AlertDialog, |
||||||
|
AlertDialogAction, |
||||||
|
AlertDialogCancel, |
||||||
|
AlertDialogContent, |
||||||
|
AlertDialogDescription, |
||||||
|
AlertDialogFooter, |
||||||
|
AlertDialogHeader, |
||||||
|
AlertDialogTitle |
||||||
|
} from '@/components/ui/alert-dialog' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
DrawerContent, |
||||||
|
DrawerDescription, |
||||||
|
DrawerFooter, |
||||||
|
DrawerHeader, |
||||||
|
DrawerTitle |
||||||
|
} from '@/components/ui/drawer' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function LogoutDialog({ |
||||||
|
open = false, |
||||||
|
setOpen |
||||||
|
}: { |
||||||
|
open: boolean |
||||||
|
setOpen: (open: boolean) => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const { account, removeAccount } = useNostr() |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<Drawer defaultOpen={false} open={open} onOpenChange={setOpen}> |
||||||
|
<DrawerContent> |
||||||
|
<DrawerHeader> |
||||||
|
<DrawerTitle>{t('Logout')}</DrawerTitle> |
||||||
|
<DrawerDescription>{t('Are you sure you want to logout?')}</DrawerDescription> |
||||||
|
</DrawerHeader> |
||||||
|
<DrawerFooter> |
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} className="w-full"> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
variant="destructive" |
||||||
|
onClick={() => { |
||||||
|
if (account) { |
||||||
|
setOpen(false) |
||||||
|
removeAccount(account) |
||||||
|
} |
||||||
|
}} |
||||||
|
className="w-full" |
||||||
|
> |
||||||
|
{t('Logout')} |
||||||
|
</Button> |
||||||
|
</DrawerFooter> |
||||||
|
</DrawerContent> |
||||||
|
</Drawer> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<AlertDialog defaultOpen={false} open={open} onOpenChange={setOpen}> |
||||||
|
<AlertDialogContent> |
||||||
|
<AlertDialogHeader> |
||||||
|
<AlertDialogTitle>{t('Logout')}</AlertDialogTitle> |
||||||
|
<AlertDialogDescription>{t('Are you sure you want to logout?')}</AlertDialogDescription> |
||||||
|
</AlertDialogHeader> |
||||||
|
<AlertDialogFooter> |
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel> |
||||||
|
<AlertDialogAction |
||||||
|
variant="destructive" |
||||||
|
onClick={() => { |
||||||
|
if (account) { |
||||||
|
removeAccount(account) |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
{t('Logout')} |
||||||
|
</AlertDialogAction> |
||||||
|
</AlertDialogFooter> |
||||||
|
</AlertDialogContent> |
||||||
|
</AlertDialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,39 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { toNotifications } from '@/lib/link' |
|
||||||
import { useSecondaryPage } from '@/PageManager' |
|
||||||
import { Bell } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function NotificationButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
|
|
||||||
if (variant === 'sidebar') { |
|
||||||
return ( |
|
||||||
<Button |
|
||||||
variant={variant} |
|
||||||
size={variant} |
|
||||||
title={t('notifications')} |
|
||||||
onClick={() => push(toNotifications())} |
|
||||||
> |
|
||||||
<Bell /> |
|
||||||
{t('Notifications')} |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Button |
|
||||||
variant={variant} |
|
||||||
size={variant} |
|
||||||
title={t('notifications')} |
|
||||||
onClick={() => push(toNotifications())} |
|
||||||
> |
|
||||||
<Bell /> |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
import PostDialog from '@/components/PostDialog' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { PencilLine } from 'lucide-react' |
|
||||||
import { useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function PostButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const [open, setOpen] = useState(false) |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<Button |
|
||||||
variant={variant} |
|
||||||
size={variant} |
|
||||||
title={t('New post')} |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
setOpen(true) |
|
||||||
}} |
|
||||||
> |
|
||||||
<PencilLine /> |
|
||||||
{variant === 'sidebar' && <div>{t('Post')}</div>} |
|
||||||
</Button> |
|
||||||
<PostDialog open={open} setOpen={setOpen} /> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,187 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
Dialog, |
|
||||||
DialogContent, |
|
||||||
DialogDescription, |
|
||||||
DialogHeader, |
|
||||||
DialogTitle |
|
||||||
} from '@/components/ui/dialog' |
|
||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area' |
|
||||||
import { Switch } from '@/components/ui/switch' |
|
||||||
import { Textarea } from '@/components/ui/textarea' |
|
||||||
import { StorageKey } from '@/constants' |
|
||||||
import { useToast } from '@/hooks/use-toast' |
|
||||||
import { createShortTextNoteDraftEvent } from '@/lib/draft-event' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { ChevronDown, LoaderCircle } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { Dispatch, useEffect, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import UserAvatar from '../UserAvatar' |
|
||||||
import Mentions from './Metions' |
|
||||||
import Preview from './Preview' |
|
||||||
import Uploader from './Uploader' |
|
||||||
|
|
||||||
export default function PostDialog({ |
|
||||||
defaultContent = '', |
|
||||||
parentEvent, |
|
||||||
open, |
|
||||||
setOpen |
|
||||||
}: { |
|
||||||
defaultContent?: string |
|
||||||
parentEvent?: Event |
|
||||||
open: boolean |
|
||||||
setOpen: Dispatch<boolean> |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { toast } = useToast() |
|
||||||
const { publish, checkLogin } = useNostr() |
|
||||||
const [content, setContent] = useState(defaultContent) |
|
||||||
const [posting, setPosting] = useState(false) |
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState(false) |
|
||||||
const [addClientTag, setAddClientTag] = useState(false) |
|
||||||
const canPost = !!content && !posting |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') |
|
||||||
}, []) |
|
||||||
|
|
||||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
|
||||||
setContent(e.target.value) |
|
||||||
} |
|
||||||
|
|
||||||
const post = async (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
checkLogin(async () => { |
|
||||||
if (!canPost) { |
|
||||||
setOpen(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
setPosting(true) |
|
||||||
try { |
|
||||||
const additionalRelayUrls: string[] = [] |
|
||||||
if (parentEvent) { |
|
||||||
const relayList = await client.fetchRelayList(parentEvent.pubkey) |
|
||||||
additionalRelayUrls.push(...relayList.read.slice(0, 5)) |
|
||||||
} |
|
||||||
const draftEvent = await createShortTextNoteDraftEvent(content, { |
|
||||||
parentEvent, |
|
||||||
addClientTag |
|
||||||
}) |
|
||||||
await publish(draftEvent, additionalRelayUrls) |
|
||||||
setContent('') |
|
||||||
setOpen(false) |
|
||||||
} catch (error) { |
|
||||||
if (error instanceof AggregateError) { |
|
||||||
error.errors.forEach((e) => |
|
||||||
toast({ |
|
||||||
variant: 'destructive', |
|
||||||
title: t('Failed to post'), |
|
||||||
description: e.message |
|
||||||
}) |
|
||||||
) |
|
||||||
} else if (error instanceof Error) { |
|
||||||
toast({ |
|
||||||
variant: 'destructive', |
|
||||||
title: t('Failed to post'), |
|
||||||
description: error.message |
|
||||||
}) |
|
||||||
} |
|
||||||
console.error(error) |
|
||||||
return |
|
||||||
} finally { |
|
||||||
setPosting(false) |
|
||||||
} |
|
||||||
toast({ |
|
||||||
title: t('Post successful'), |
|
||||||
description: t('Your post has been published') |
|
||||||
}) |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const onAddClientTagChange = (checked: boolean) => { |
|
||||||
setAddClientTag(checked) |
|
||||||
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString()) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Dialog open={open} onOpenChange={setOpen}> |
|
||||||
<DialogContent className="p-0" withoutClose> |
|
||||||
<ScrollArea className="px-4 h-full max-h-screen"> |
|
||||||
<div className="space-y-4 px-2 py-6"> |
|
||||||
<DialogHeader> |
|
||||||
<DialogTitle> |
|
||||||
{parentEvent ? ( |
|
||||||
<div className="flex gap-2 items-center max-w-full"> |
|
||||||
<div className="shrink-0">{t('Reply to')}</div> |
|
||||||
<UserAvatar userId={parentEvent.pubkey} size="tiny" /> |
|
||||||
<div className="truncate">{parentEvent.content}</div> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
t('New post') |
|
||||||
)} |
|
||||||
</DialogTitle> |
|
||||||
<DialogDescription className="hidden" /> |
|
||||||
</DialogHeader> |
|
||||||
<Textarea |
|
||||||
className="h-32" |
|
||||||
onChange={handleTextareaChange} |
|
||||||
value={content} |
|
||||||
placeholder={t('Write something...')} |
|
||||||
/> |
|
||||||
{content && <Preview content={content} />} |
|
||||||
<div className="flex items-center justify-between"> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<Uploader setContent={setContent} /> |
|
||||||
<Button |
|
||||||
variant="link" |
|
||||||
className="text-foreground gap-0 px-0" |
|
||||||
onClick={() => setShowMoreOptions((pre) => !pre)} |
|
||||||
> |
|
||||||
{t('More options')} |
|
||||||
<ChevronDown |
|
||||||
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} |
|
||||||
/> |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<Mentions content={content} parentEvent={parentEvent} /> |
|
||||||
<Button |
|
||||||
variant="secondary" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
setOpen(false) |
|
||||||
}} |
|
||||||
> |
|
||||||
{t('Cancel')} |
|
||||||
</Button> |
|
||||||
<Button type="submit" disabled={!canPost} onClick={post}> |
|
||||||
{posting && <LoaderCircle className="animate-spin" />} |
|
||||||
{parentEvent ? t('Reply') : t('Post')} |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{showMoreOptions && ( |
|
||||||
<div className="space-y-2"> |
|
||||||
<div className="flex items-center space-x-2"> |
|
||||||
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label> |
|
||||||
<Switch |
|
||||||
id="add-client-tag" |
|
||||||
checked={addClientTag} |
|
||||||
onCheckedChange={onAddClientTagChange} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div className="text-muted-foreground text-xs"> |
|
||||||
{t('Show others this was sent via Jumble')} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</ScrollArea> |
|
||||||
</DialogContent> |
|
||||||
</Dialog> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,174 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import { Textarea } from '@/components/ui/textarea' |
||||||
|
import { StorageKey } from '@/constants' |
||||||
|
import { useToast } from '@/hooks/use-toast' |
||||||
|
import { createShortTextNoteDraftEvent } from '@/lib/draft-event' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { ChevronDown, LoaderCircle } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useEffect, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Mentions from './Mentions' |
||||||
|
import Preview from './Preview' |
||||||
|
import Uploader from './Uploader' |
||||||
|
|
||||||
|
export default function PostContent({ |
||||||
|
defaultContent = '', |
||||||
|
parentEvent, |
||||||
|
close |
||||||
|
}: { |
||||||
|
defaultContent?: string |
||||||
|
parentEvent?: Event |
||||||
|
close: () => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { toast } = useToast() |
||||||
|
const { publish, checkLogin } = useNostr() |
||||||
|
const [content, setContent] = useState(defaultContent) |
||||||
|
const [posting, setPosting] = useState(false) |
||||||
|
const [showMoreOptions, setShowMoreOptions] = useState(false) |
||||||
|
const [addClientTag, setAddClientTag] = useState(false) |
||||||
|
const canPost = !!content && !posting |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||||
|
setContent(e.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const post = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
checkLogin(async () => { |
||||||
|
if (!canPost) { |
||||||
|
close() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setPosting(true) |
||||||
|
try { |
||||||
|
const additionalRelayUrls: string[] = [] |
||||||
|
if (parentEvent) { |
||||||
|
const relayList = await client.fetchRelayList(parentEvent.pubkey) |
||||||
|
additionalRelayUrls.push(...relayList.read.slice(0, 5)) |
||||||
|
} |
||||||
|
const draftEvent = await createShortTextNoteDraftEvent(content, { |
||||||
|
parentEvent, |
||||||
|
addClientTag |
||||||
|
}) |
||||||
|
await publish(draftEvent, additionalRelayUrls) |
||||||
|
setContent('') |
||||||
|
close() |
||||||
|
} catch (error) { |
||||||
|
if (error instanceof AggregateError) { |
||||||
|
error.errors.forEach((e) => |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: t('Failed to post'), |
||||||
|
description: e.message |
||||||
|
}) |
||||||
|
) |
||||||
|
} else if (error instanceof Error) { |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: t('Failed to post'), |
||||||
|
description: error.message |
||||||
|
}) |
||||||
|
} |
||||||
|
console.error(error) |
||||||
|
return |
||||||
|
} finally { |
||||||
|
setPosting(false) |
||||||
|
} |
||||||
|
toast({ |
||||||
|
title: t('Post successful'), |
||||||
|
description: t('Your post has been published') |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const onAddClientTagChange = (checked: boolean) => { |
||||||
|
setAddClientTag(checked) |
||||||
|
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString()) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-4"> |
||||||
|
<Textarea |
||||||
|
className="h-32" |
||||||
|
onChange={handleTextareaChange} |
||||||
|
value={content} |
||||||
|
placeholder={t('Write something...')} |
||||||
|
/> |
||||||
|
{content && <Preview content={content} />} |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<Uploader setContent={setContent} /> |
||||||
|
<Button |
||||||
|
variant="link" |
||||||
|
className="text-foreground gap-0 px-0" |
||||||
|
onClick={() => setShowMoreOptions((pre) => !pre)} |
||||||
|
> |
||||||
|
{t('More options')} |
||||||
|
<ChevronDown |
||||||
|
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} |
||||||
|
/> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<Mentions content={content} parentEvent={parentEvent} /> |
||||||
|
<div className="flex gap-2 items-center max-sm:hidden"> |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
close() |
||||||
|
}} |
||||||
|
> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button type="submit" disabled={!canPost} onClick={post}> |
||||||
|
{posting && <LoaderCircle className="animate-spin" />} |
||||||
|
{parentEvent ? t('Reply') : t('Post')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{showMoreOptions && ( |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label> |
||||||
|
<Switch |
||||||
|
id="add-client-tag" |
||||||
|
checked={addClientTag} |
||||||
|
onCheckedChange={onAddClientTagChange} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="text-muted-foreground text-xs"> |
||||||
|
{t('Show others this was sent via Jumble')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="flex gap-2 items-center justify-around sm:hidden"> |
||||||
|
<Button |
||||||
|
className="w-full" |
||||||
|
variant="secondary" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
close() |
||||||
|
}} |
||||||
|
> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}> |
||||||
|
{posting && <LoaderCircle className="animate-spin" />} |
||||||
|
{parentEvent ? t('Reply') : t('Post')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { SimpleUserAvatar } from '../UserAvatar' |
||||||
|
|
||||||
|
export default function Title({ parentEvent }: { parentEvent?: Event }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return parentEvent ? ( |
||||||
|
<div className="flex gap-2 items-center w-full"> |
||||||
|
<div className="shrink-0">{t('Reply to')}</div> |
||||||
|
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" /> |
||||||
|
<div className="flex-1 w-0 truncate">{parentEvent.content}</div> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
t('New post') |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
DrawerContent, |
||||||
|
DrawerDescription, |
||||||
|
DrawerHeader, |
||||||
|
DrawerTitle |
||||||
|
} from '@/components/ui/drawer' |
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { Dispatch } from 'react' |
||||||
|
import PostContent from './PostContent' |
||||||
|
import Title from './Title' |
||||||
|
|
||||||
|
export default function PostEditor({ |
||||||
|
defaultContent = '', |
||||||
|
parentEvent, |
||||||
|
open, |
||||||
|
setOpen |
||||||
|
}: { |
||||||
|
defaultContent?: string |
||||||
|
parentEvent?: Event |
||||||
|
open: boolean |
||||||
|
setOpen: Dispatch<boolean> |
||||||
|
}) { |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<Drawer open={open} onOpenChange={setOpen}> |
||||||
|
<DrawerContent className="h-full"> |
||||||
|
<DrawerHeader> |
||||||
|
<DrawerTitle className="text-start"> |
||||||
|
<Title parentEvent={parentEvent} /> |
||||||
|
</DrawerTitle> |
||||||
|
<DrawerDescription className="hidden" /> |
||||||
|
</DrawerHeader> |
||||||
|
<div className="overflow-auto py-2 px-4"> |
||||||
|
<PostContent |
||||||
|
defaultContent={defaultContent} |
||||||
|
parentEvent={parentEvent} |
||||||
|
close={() => setOpen(false)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</DrawerContent> |
||||||
|
</Drawer> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={open} onOpenChange={setOpen}> |
||||||
|
<DialogContent className="p-0" withoutClose> |
||||||
|
<ScrollArea className="px-4 h-full max-h-screen"> |
||||||
|
<div className="space-y-4 px-2 py-6"> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle> |
||||||
|
<Title parentEvent={parentEvent} /> |
||||||
|
</DialogTitle> |
||||||
|
<DialogDescription className="hidden" /> |
||||||
|
</DialogHeader> |
||||||
|
<PostContent |
||||||
|
defaultContent={defaultContent} |
||||||
|
parentEvent={parentEvent} |
||||||
|
close={() => setOpen(false)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</ScrollArea> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,13 +1,33 @@ |
|||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' |
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
import { QrCode } from 'lucide-react' |
import { QrCode } from 'lucide-react' |
||||||
import { nip19 } from 'nostr-tools' |
import { nip19 } from 'nostr-tools' |
||||||
import { useMemo } from 'react' |
|
||||||
import { QRCodeSVG } from 'qrcode.react' |
import { QRCodeSVG } from 'qrcode.react' |
||||||
|
import { useMemo } from 'react' |
||||||
|
|
||||||
export default function QrCodePopover({ pubkey }: { pubkey: string }) { |
export default function QrCodePopover({ pubkey }: { pubkey: string }) { |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) |
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) |
||||||
if (!npub) return null |
if (!npub) return null |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<Drawer> |
||||||
|
<DrawerTrigger> |
||||||
|
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"> |
||||||
|
<QrCode size={14} /> |
||||||
|
</div> |
||||||
|
</DrawerTrigger> |
||||||
|
<DrawerContent className="h-1/2"> |
||||||
|
<div className="flex justify-center items-center h-full"> |
||||||
|
<QRCodeSVG size={300} value={`nostr:${npub}`} /> |
||||||
|
</div> |
||||||
|
</DrawerContent> |
||||||
|
</Drawer> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
return ( |
return ( |
||||||
<Popover> |
<Popover> |
||||||
<PopoverTrigger> |
<PopoverTrigger> |
||||||
@ -1,19 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { usePrimaryPage } from '@/PageManager' |
|
||||||
import { RefreshCcw } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function RefreshButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { refresh } = usePrimaryPage() |
|
||||||
return ( |
|
||||||
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}> |
|
||||||
<RefreshCcw /> |
|
||||||
{variant === 'sidebar' && <div>{t('Refresh')}</div>} |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
import RelaySettings from '@/components/RelaySettings' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area' |
|
||||||
import { toRelaySettings } from '@/lib/link' |
|
||||||
import { SecondaryPageLink } from '@/PageManager' |
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
||||||
import { Server } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function RelaySettingsButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { isSmallScreen } = useScreenSize() |
|
||||||
|
|
||||||
if (isSmallScreen) { |
|
||||||
return ( |
|
||||||
<SecondaryPageLink to={toRelaySettings()}> |
|
||||||
<Button variant={variant} size={variant} title={t('Relay settings')}> |
|
||||||
<Server /> |
|
||||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>} |
|
||||||
</Button> |
|
||||||
</SecondaryPageLink> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Popover> |
|
||||||
<PopoverTrigger asChild> |
|
||||||
<Button variant={variant} size={variant} title={t('Relay settings')}> |
|
||||||
<Server /> |
|
||||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>} |
|
||||||
</Button> |
|
||||||
</PopoverTrigger> |
|
||||||
<PopoverContent |
|
||||||
className="w-96 h-[450px] p-0" |
|
||||||
side={variant === 'titlebar' ? 'bottom' : 'right'} |
|
||||||
> |
|
||||||
<ScrollArea className="h-full"> |
|
||||||
<div className="p-4"> |
|
||||||
<RelaySettings /> |
|
||||||
</div> |
|
||||||
</ScrollArea> |
|
||||||
</PopoverContent> |
|
||||||
</Popover> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,24 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Search } from 'lucide-react' |
|
||||||
import { useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { SearchDialog } from '../SearchDialog' |
|
||||||
|
|
||||||
export default function RefreshButton({ |
|
||||||
variant = 'titlebar' |
|
||||||
}: { |
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar' |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const [open, setOpen] = useState(false) |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}> |
|
||||||
<Search /> |
|
||||||
{variant === 'sidebar' && <div>{t('Search')}</div>} |
|
||||||
</Button> |
|
||||||
<SearchDialog open={open} setOpen={setOpen} /> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,89 @@ |
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
DropdownMenu, |
||||||
|
DropdownMenuContent, |
||||||
|
DropdownMenuItem, |
||||||
|
DropdownMenuTrigger |
||||||
|
} from '@/components/ui/dropdown-menu' |
||||||
|
import { useFetchProfile } from '@/hooks' |
||||||
|
import { toProfile, toSettings } from '@/lib/link' |
||||||
|
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { LogIn } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import LoginDialog from '../LoginDialog' |
||||||
|
import LogoutDialog from '../LogoutDialog' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function AccountButton() { |
||||||
|
const { pubkey } = useNostr() |
||||||
|
|
||||||
|
if (pubkey) { |
||||||
|
return <ProfileButton /> |
||||||
|
} else { |
||||||
|
return <LoginButton /> |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function ProfileButton() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { account } = useNostr() |
||||||
|
const pubkey = account?.pubkey |
||||||
|
const { profile } = useFetchProfile(pubkey) |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false) |
||||||
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) |
||||||
|
if (!pubkey) return null |
||||||
|
|
||||||
|
const defaultAvatar = generateImageByPubkey(pubkey) |
||||||
|
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar } |
||||||
|
|
||||||
|
return ( |
||||||
|
<DropdownMenu> |
||||||
|
<DropdownMenuTrigger asChild> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
className="clickable shadow-none p-2 xl:px-2 xl:py-2 w-12 h-12 xl:w-full xl:h-auto flex items-center bg-transparent text-foreground hover:text-accent-foreground rounded-lg justify-start gap-4 text-lg font-semibold" |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center flex-1 w-0"> |
||||||
|
<Avatar className="w-8 h-8"> |
||||||
|
<AvatarImage src={avatar} /> |
||||||
|
<AvatarFallback> |
||||||
|
<img src={defaultAvatar} /> |
||||||
|
</AvatarFallback> |
||||||
|
</Avatar> |
||||||
|
<div className="truncate font-semibold text-lg">{username}</div> |
||||||
|
</div> |
||||||
|
</Button> |
||||||
|
</DropdownMenuTrigger> |
||||||
|
<DropdownMenuContent> |
||||||
|
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem> |
||||||
|
<DropdownMenuItem onClick={() => push(toSettings())}>{t('Settings')}</DropdownMenuItem> |
||||||
|
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}> |
||||||
|
{t('Switch account')} |
||||||
|
</DropdownMenuItem> |
||||||
|
<DropdownMenuItem |
||||||
|
className="text-destructive focus:text-destructive" |
||||||
|
onClick={() => setLogoutDialogOpen(true)} |
||||||
|
> |
||||||
|
{t('Logout')} |
||||||
|
</DropdownMenuItem> |
||||||
|
</DropdownMenuContent> |
||||||
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> |
||||||
|
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> |
||||||
|
</DropdownMenu> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function LoginButton() { |
||||||
|
const { checkLogin } = useNostr() |
||||||
|
|
||||||
|
return ( |
||||||
|
<SidebarItem onClick={() => checkLogin()} title="Login"> |
||||||
|
<LogIn strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Home } from 'lucide-react' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function HomeButton() { |
||||||
|
const { navigate, current } = usePrimaryPage() |
||||||
|
|
||||||
|
return ( |
||||||
|
<SidebarItem title="Home" onClick={() => navigate('home')} active={current === 'home'}> |
||||||
|
<Home strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import { usePrimaryPage } from '@/PageManager' |
||||||
|
import { Bell } from 'lucide-react' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function NotificationsButton() { |
||||||
|
const { navigate, current } = usePrimaryPage() |
||||||
|
|
||||||
|
return ( |
||||||
|
<SidebarItem |
||||||
|
title="Notifications" |
||||||
|
onClick={() => navigate('notifications')} |
||||||
|
active={current === 'notifications'} |
||||||
|
> |
||||||
|
<Bell strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
import PostEditor from '@/components/PostEditor' |
||||||
|
import { PencilLine } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function PostButton() { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<SidebarItem |
||||||
|
title="New post" |
||||||
|
description="Post" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
setOpen(true) |
||||||
|
}} |
||||||
|
> |
||||||
|
<PencilLine strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
<PostEditor open={open} setOpen={setOpen} /> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
import { Search } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
import { SearchDialog } from '../SearchDialog' |
||||||
|
import SidebarItem from './SidebarItem' |
||||||
|
|
||||||
|
export default function SearchButton() { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<SidebarItem |
||||||
|
title="Search" |
||||||
|
description="Search" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
setOpen(true) |
||||||
|
}} |
||||||
|
> |
||||||
|
<Search strokeWidth={3} /> |
||||||
|
</SidebarItem> |
||||||
|
<SearchDialog open={open} setOpen={setOpen} /> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import { Button, ButtonProps } from '@/components/ui/button' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { forwardRef } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const SidebarItem = forwardRef< |
||||||
|
HTMLButtonElement, |
||||||
|
ButtonProps & { title: string; description?: string; active?: boolean } |
||||||
|
>(({ children, title, description, className, active, ...props }, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Button |
||||||
|
className={cn( |
||||||
|
'flex shadow-none items-center bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4', |
||||||
|
active && 'text-primary disabled:opacity-100', |
||||||
|
className |
||||||
|
)} |
||||||
|
disabled={active} |
||||||
|
variant="ghost" |
||||||
|
title={t(title)} |
||||||
|
ref={ref} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
{children} |
||||||
|
<div className="max-xl:hidden">{t(description ?? title)}</div> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
}) |
||||||
|
SidebarItem.displayName = 'SidebarItem' |
||||||
|
export default SidebarItem |
||||||
@ -1,35 +1,25 @@ |
|||||||
|
import Icon from '@/assets/Icon' |
||||||
import Logo from '@/assets/Logo' |
import Logo from '@/assets/Logo' |
||||||
import { Button } from '@/components/ui/button' |
import AccountButton from './AccountButton' |
||||||
import { Info } from 'lucide-react' |
import HomeButton from './HomeButton' |
||||||
import { useTranslation } from 'react-i18next' |
import NotificationsButton from './NotificationButton' |
||||||
import AboutInfoDialog from '../AboutInfoDialog' |
import PostButton from './PostButton' |
||||||
import AccountButton from '../AccountButton' |
import SearchButton from './SearchButton' |
||||||
import NotificationButton from '../NotificationButton' |
|
||||||
import PostButton from '../PostButton' |
|
||||||
import RelaySettingsButton from '../RelaySettingsButton' |
|
||||||
import SearchButton from '../SearchButton' |
|
||||||
|
|
||||||
export default function PrimaryPageSidebar() { |
export default function PrimaryPageSidebar() { |
||||||
const { t } = useTranslation() |
|
||||||
return ( |
return ( |
||||||
<div className="w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-10 pl-4 justify-between relative"> |
<div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0"> |
||||||
<div className="absolute top-0 left-0 h-11 w-full" /> |
|
||||||
<div className="space-y-2"> |
<div className="space-y-2"> |
||||||
<div className="ml-4 mb-8 w-40"> |
<div className="px-2 mb-10 w-full"> |
||||||
<Logo /> |
<Icon className="xl:hidden" /> |
||||||
|
<Logo className="max-xl:hidden" /> |
||||||
</div> |
</div> |
||||||
<PostButton variant="sidebar" /> |
<HomeButton /> |
||||||
<RelaySettingsButton variant="sidebar" /> |
<NotificationsButton /> |
||||||
<NotificationButton variant="sidebar" /> |
<SearchButton /> |
||||||
<SearchButton variant="sidebar" /> |
<PostButton /> |
||||||
<AboutInfoDialog> |
|
||||||
<Button variant="sidebar" size="sidebar"> |
|
||||||
<Info /> |
|
||||||
{t('About')} |
|
||||||
</Button> |
|
||||||
</AboutInfoDialog> |
|
||||||
</div> |
</div> |
||||||
<AccountButton variant="sidebar" /> |
<AccountButton /> |
||||||
</div> |
</div> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,121 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' |
||||||
|
|
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { buttonVariants } from '@/components/ui/button' |
||||||
|
import { VariantProps } from 'class-variance-authority' |
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root |
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger |
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal |
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<AlertDialogPrimitive.Overlay |
||||||
|
className={cn( |
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
ref={ref} |
||||||
|
/> |
||||||
|
)) |
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName |
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<AlertDialogPortal> |
||||||
|
<AlertDialogOverlay /> |
||||||
|
<AlertDialogPrimitive.Content |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
</AlertDialogPortal> |
||||||
|
)) |
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName |
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} /> |
||||||
|
) |
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader' |
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div |
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter' |
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<AlertDialogPrimitive.Title |
||||||
|
ref={ref} |
||||||
|
className={cn('text-lg font-semibold', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName |
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<AlertDialogPrimitive.Description |
||||||
|
ref={ref} |
||||||
|
className={cn('text-sm text-muted-foreground', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName |
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & |
||||||
|
VariantProps<typeof buttonVariants> |
||||||
|
>(({ className, variant, size, ...props }, ref) => ( |
||||||
|
<AlertDialogPrimitive.Action |
||||||
|
ref={ref} |
||||||
|
className={cn(buttonVariants({ variant, size, className }))} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName |
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef< |
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, |
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<AlertDialogPrimitive.Cancel |
||||||
|
ref={ref} |
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName |
||||||
|
|
||||||
|
export { |
||||||
|
AlertDialog, |
||||||
|
AlertDialogPortal, |
||||||
|
AlertDialogOverlay, |
||||||
|
AlertDialogTrigger, |
||||||
|
AlertDialogContent, |
||||||
|
AlertDialogHeader, |
||||||
|
AlertDialogFooter, |
||||||
|
AlertDialogTitle, |
||||||
|
AlertDialogDescription, |
||||||
|
AlertDialogAction, |
||||||
|
AlertDialogCancel |
||||||
|
} |
||||||
@ -0,0 +1,101 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul' |
||||||
|
|
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
const Drawer = ({ |
||||||
|
shouldScaleBackground = true, |
||||||
|
...props |
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( |
||||||
|
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} /> |
||||||
|
) |
||||||
|
Drawer.displayName = 'Drawer' |
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger |
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal |
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close |
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef< |
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>, |
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<DrawerPrimitive.Overlay |
||||||
|
ref={ref} |
||||||
|
className={cn('fixed inset-0 z-50 bg-black/80', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName |
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef< |
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>, |
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> |
||||||
|
>(({ className, children, ...props }, ref) => ( |
||||||
|
<DrawerPortal> |
||||||
|
<DrawerOverlay /> |
||||||
|
<DrawerPrimitive.Content |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] sm:border bg-background', |
||||||
|
className |
||||||
|
)} |
||||||
|
style={{ |
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)' |
||||||
|
}} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> |
||||||
|
{children} |
||||||
|
</DrawerPrimitive.Content> |
||||||
|
</DrawerPortal> |
||||||
|
)) |
||||||
|
DrawerContent.displayName = 'DrawerContent' |
||||||
|
|
||||||
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)} {...props} /> |
||||||
|
) |
||||||
|
DrawerHeader.displayName = 'DrawerHeader' |
||||||
|
|
||||||
|
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} /> |
||||||
|
) |
||||||
|
DrawerFooter.displayName = 'DrawerFooter' |
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef< |
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>, |
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<DrawerPrimitive.Title |
||||||
|
ref={ref} |
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName |
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef< |
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>, |
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<DrawerPrimitive.Description |
||||||
|
ref={ref} |
||||||
|
className={cn('text-sm text-muted-foreground', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName |
||||||
|
|
||||||
|
export { |
||||||
|
Drawer, |
||||||
|
DrawerPortal, |
||||||
|
DrawerOverlay, |
||||||
|
DrawerTrigger, |
||||||
|
DrawerClose, |
||||||
|
DrawerContent, |
||||||
|
DrawerHeader, |
||||||
|
DrawerFooter, |
||||||
|
DrawerTitle, |
||||||
|
DrawerDescription |
||||||
|
} |
||||||
@ -0,0 +1,150 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select' |
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react' |
||||||
|
|
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root |
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group |
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value |
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> |
||||||
|
>(({ className, children, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.Trigger |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
{children} |
||||||
|
<SelectPrimitive.Icon asChild> |
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" /> |
||||||
|
</SelectPrimitive.Icon> |
||||||
|
</SelectPrimitive.Trigger> |
||||||
|
)) |
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName |
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.ScrollUpButton |
||||||
|
ref={ref} |
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<ChevronUp className="h-4 w-4" /> |
||||||
|
</SelectPrimitive.ScrollUpButton> |
||||||
|
)) |
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName |
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.ScrollDownButton |
||||||
|
ref={ref} |
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<ChevronDown className="h-4 w-4" /> |
||||||
|
</SelectPrimitive.ScrollDownButton> |
||||||
|
)) |
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName |
||||||
|
|
||||||
|
const SelectContent = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> |
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => ( |
||||||
|
<SelectPrimitive.Portal> |
||||||
|
<SelectPrimitive.Content |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', |
||||||
|
position === 'popper' && |
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', |
||||||
|
className |
||||||
|
)} |
||||||
|
position={position} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<SelectScrollUpButton /> |
||||||
|
<SelectPrimitive.Viewport |
||||||
|
className={cn( |
||||||
|
'p-1', |
||||||
|
position === 'popper' && |
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]' |
||||||
|
)} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</SelectPrimitive.Viewport> |
||||||
|
<SelectScrollDownButton /> |
||||||
|
</SelectPrimitive.Content> |
||||||
|
</SelectPrimitive.Portal> |
||||||
|
)) |
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName |
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.Label |
||||||
|
ref={ref} |
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName |
||||||
|
|
||||||
|
const SelectItem = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> |
||||||
|
>(({ className, children, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.Item |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> |
||||||
|
<SelectPrimitive.ItemIndicator> |
||||||
|
<Check className="h-4 w-4" /> |
||||||
|
</SelectPrimitive.ItemIndicator> |
||||||
|
</span> |
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
||||||
|
</SelectPrimitive.Item> |
||||||
|
)) |
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName |
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef< |
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SelectPrimitive.Separator |
||||||
|
ref={ref} |
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName |
||||||
|
|
||||||
|
export { |
||||||
|
Select, |
||||||
|
SelectGroup, |
||||||
|
SelectValue, |
||||||
|
SelectTrigger, |
||||||
|
SelectContent, |
||||||
|
SelectLabel, |
||||||
|
SelectItem, |
||||||
|
SelectSeparator, |
||||||
|
SelectScrollUpButton, |
||||||
|
SelectScrollDownButton |
||||||
|
} |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog' |
||||||
|
import { cva, type VariantProps } from 'class-variance-authority' |
||||||
|
import { X } from 'lucide-react' |
||||||
|
|
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root |
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger |
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close |
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal |
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef< |
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SheetPrimitive.Overlay |
||||||
|
className={cn( |
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
ref={ref} |
||||||
|
/> |
||||||
|
)) |
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName |
||||||
|
|
||||||
|
const sheetVariants = cva( |
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', |
||||||
|
{ |
||||||
|
variants: { |
||||||
|
side: { |
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', |
||||||
|
bottom: |
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', |
||||||
|
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', |
||||||
|
right: |
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm' |
||||||
|
} |
||||||
|
}, |
||||||
|
defaultVariants: { |
||||||
|
side: 'right' |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
interface SheetContentProps |
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, |
||||||
|
VariantProps<typeof sheetVariants> {} |
||||||
|
|
||||||
|
const SheetContent = React.forwardRef< |
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>, |
||||||
|
SheetContentProps |
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => ( |
||||||
|
<SheetPortal> |
||||||
|
<SheetOverlay /> |
||||||
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> |
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> |
||||||
|
<X className="h-4 w-4" /> |
||||||
|
<span className="sr-only">Close</span> |
||||||
|
</SheetPrimitive.Close> |
||||||
|
{children} |
||||||
|
</SheetPrimitive.Content> |
||||||
|
</SheetPortal> |
||||||
|
)) |
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName |
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} /> |
||||||
|
) |
||||||
|
SheetHeader.displayName = 'SheetHeader' |
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( |
||||||
|
<div |
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
SheetFooter.displayName = 'SheetFooter' |
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef< |
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SheetPrimitive.Title |
||||||
|
ref={ref} |
||||||
|
className={cn('text-lg font-semibold text-foreground', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName |
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef< |
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>, |
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<SheetPrimitive.Description |
||||||
|
ref={ref} |
||||||
|
className={cn('text-sm text-muted-foreground', className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName |
||||||
|
|
||||||
|
export { |
||||||
|
Sheet, |
||||||
|
SheetPortal, |
||||||
|
SheetOverlay, |
||||||
|
SheetTrigger, |
||||||
|
SheetClose, |
||||||
|
SheetContent, |
||||||
|
SheetHeader, |
||||||
|
SheetFooter, |
||||||
|
SheetTitle, |
||||||
|
SheetDescription |
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
import AccountManager from '@/components/AccountManager' |
||||||
|
import LoginDialog from '@/components/LoginDialog' |
||||||
|
import LogoutDialog from '@/components/LogoutDialog' |
||||||
|
import PubkeyCopy from '@/components/PubkeyCopy' |
||||||
|
import QrCodePopover from '@/components/QrCodePopover' |
||||||
|
import { Separator } from '@/components/ui/separator' |
||||||
|
import { SimpleUserAvatar } from '@/components/UserAvatar' |
||||||
|
import { SimpleUsername } from '@/components/Username' |
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||||
|
import { toProfile, toSettings } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound } from 'lucide-react' |
||||||
|
import { HTMLProps, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function MePage() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false) |
||||||
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return ( |
||||||
|
<PrimaryPageLayout pageName="home"> |
||||||
|
<div className="flex flex-col p-4 gap-4 overflow-auto"> |
||||||
|
<AccountManager /> |
||||||
|
</div> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<PrimaryPageLayout pageName="home"> |
||||||
|
<div className="flex gap-4 items-center p-4"> |
||||||
|
<SimpleUserAvatar userId={pubkey} size="big" /> |
||||||
|
<div className="space-y-1"> |
||||||
|
<SimpleUsername |
||||||
|
className="text-xl font-semibold truncate" |
||||||
|
userId={pubkey} |
||||||
|
skeletonClassName="h-6 w-32" |
||||||
|
/> |
||||||
|
<div className="flex gap-1 mt-1"> |
||||||
|
<PubkeyCopy pubkey={pubkey} /> |
||||||
|
<QrCodePopover pubkey={pubkey} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="mt-4"> |
||||||
|
<ItemGroup> |
||||||
|
<Item onClick={() => push(toProfile(pubkey))}> |
||||||
|
<UserRound /> |
||||||
|
{t('Profile')} |
||||||
|
</Item> |
||||||
|
</ItemGroup> |
||||||
|
<ItemGroup> |
||||||
|
<Item onClick={() => push(toSettings())}> |
||||||
|
<Settings /> |
||||||
|
{t('Settings')} |
||||||
|
</Item> |
||||||
|
</ItemGroup> |
||||||
|
<ItemGroup> |
||||||
|
<Item onClick={() => setLoginDialogOpen(true)}> |
||||||
|
<ArrowDownUp /> {t('Switch account')} |
||||||
|
</Item> |
||||||
|
<Separator className="bg-background" /> |
||||||
|
<Item |
||||||
|
className="text-destructive focus:text-destructive" |
||||||
|
onClick={() => setLogoutDialogOpen(true)} |
||||||
|
hideChevron |
||||||
|
> |
||||||
|
<LogOut /> |
||||||
|
{t('Logout')} |
||||||
|
</Item> |
||||||
|
</ItemGroup> |
||||||
|
</div> |
||||||
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> |
||||||
|
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} /> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function Item({ |
||||||
|
children, |
||||||
|
className, |
||||||
|
hideChevron = false, |
||||||
|
...props |
||||||
|
}: HTMLProps<HTMLDivElement> & { hideChevron?: boolean }) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex items-center justify-between px-4 py-2 w-full clickable rounded-lg [&_svg]:size-4 [&_svg]:shrink-0', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-4">{children}</div> |
||||||
|
{!hideChevron && <ChevronRight />} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function ItemGroup({ children }: { children: React.ReactNode }) { |
||||||
|
return <div className="rounded-lg m-4 bg-muted/40">{children}</div> |
||||||
|
} |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
import FeedSwitcher from '@/components/FeedSwitcher' |
||||||
|
import { Drawer, DrawerContent } from '@/components/ui/drawer' |
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||||
|
import { simplifyUrl } from '@/lib/url' |
||||||
|
import { useFeed } from '@/providers/FeedProvider' |
||||||
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { ChevronDown, Server, UsersRound } from 'lucide-react' |
||||||
|
import { forwardRef, HTMLAttributes, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function FeedButton() { |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<FeedSwitcherTrigger onClick={() => setOpen(true)} /> |
||||||
|
<Drawer open={open} onOpenChange={setOpen}> |
||||||
|
<DrawerContent className="max-h-[80vh]"> |
||||||
|
<div className="p-4 overflow-auto"> |
||||||
|
<FeedSwitcher close={() => setOpen(false)} /> |
||||||
|
</div> |
||||||
|
</DrawerContent> |
||||||
|
</Drawer> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Popover open={open} onOpenChange={setOpen}> |
||||||
|
<PopoverTrigger asChild> |
||||||
|
<FeedSwitcherTrigger /> |
||||||
|
</PopoverTrigger> |
||||||
|
<PopoverContent side="bottom" className="w-96 p-4 max-h-[80vh] overflow-auto"> |
||||||
|
<FeedSwitcher close={() => setOpen(false)} /> |
||||||
|
</PopoverContent> |
||||||
|
</Popover> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
||||||
|
(props, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { feedType } = useFeed() |
||||||
|
const { relayGroups, temporaryRelayUrls } = useRelaySettings() |
||||||
|
const activeGroup = relayGroups.find((group) => group.isActive) |
||||||
|
const title = |
||||||
|
feedType === 'following' |
||||||
|
? t('Following') |
||||||
|
: temporaryRelayUrls.length > 0 |
||||||
|
? temporaryRelayUrls.length === 1 |
||||||
|
? simplifyUrl(temporaryRelayUrls[0]) |
||||||
|
: t('Temporary') |
||||||
|
: activeGroup |
||||||
|
? activeGroup.relayUrls.length === 1 |
||||||
|
? simplifyUrl(activeGroup.relayUrls[0]) |
||||||
|
: activeGroup.groupName |
||||||
|
: t('Choose a relay collection') |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex items-center gap-2 clickable px-3 h-full rounded-lg" |
||||||
|
ref={ref} |
||||||
|
{...props} |
||||||
|
> |
||||||
|
{feedType === 'following' ? <UsersRound /> : <Server />} |
||||||
|
<div className="text-lg font-semibold">{title}</div> |
||||||
|
<ChevronDown /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import { SearchDialog } from '@/components/SearchDialog' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Search } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
|
||||||
|
export default function SearchButton() { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Button variant="ghost" size="titlebar-icon" onClick={() => setOpen(true)}> |
||||||
|
<Search /> |
||||||
|
</Button> |
||||||
|
<SearchDialog open={open} setOpen={setOpen} /> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,48 +1,69 @@ |
|||||||
import NoteList from '@/components/NoteList' |
import NoteList from '@/components/NoteList' |
||||||
import RelaySettings from '@/components/RelaySettings' |
import { BIG_RELAY_URLS } from '@/constants' |
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area' |
|
||||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||||
|
import { useFeed } from '@/providers/FeedProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
import { useRelaySettings } from '@/providers/RelaySettingsProvider' |
||||||
import { useEffect, useRef } from 'react' |
import { useEffect, useMemo, useRef } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import FeedButton from './FeedButton' |
||||||
|
import SearchButton from './SearchButton' |
||||||
|
|
||||||
export default function NoteListPage() { |
export default function NoteListPage() { |
||||||
|
const { t } = useTranslation() |
||||||
const layoutRef = useRef<{ scrollToTop: () => void }>(null) |
const layoutRef = useRef<{ scrollToTop: () => void }>(null) |
||||||
const { relayUrls } = useRelaySettings() |
const { feedType } = useFeed() |
||||||
const relayUrlsString = JSON.stringify(relayUrls) |
const { relayUrls, temporaryRelayUrls } = useRelaySettings() |
||||||
|
const { pubkey, relayList, followings } = useNostr() |
||||||
|
const urls = useMemo(() => { |
||||||
|
return feedType === 'following' |
||||||
|
? relayList?.read.length |
||||||
|
? relayList.read.slice(0, 4) |
||||||
|
: BIG_RELAY_URLS |
||||||
|
: temporaryRelayUrls.length > 0 |
||||||
|
? temporaryRelayUrls |
||||||
|
: relayUrls |
||||||
|
}, [feedType, relayUrls, relayList, temporaryRelayUrls]) |
||||||
|
|
||||||
useEffect(() => { |
useEffect(() => { |
||||||
if (layoutRef.current) { |
if (layoutRef.current) { |
||||||
layoutRef.current.scrollToTop() |
layoutRef.current.scrollToTop() |
||||||
} |
} |
||||||
}, [relayUrlsString]) |
}, [JSON.stringify(relayUrls), feedType]) |
||||||
|
|
||||||
if (!relayUrls.length) { |
|
||||||
return ( |
|
||||||
<PrimaryPageLayout> |
|
||||||
<div className="w-full text-center"> |
|
||||||
<Popover> |
|
||||||
<PopoverTrigger asChild> |
|
||||||
<Button title="relay settings" size="lg"> |
|
||||||
Choose a relay group |
|
||||||
</Button> |
|
||||||
</PopoverTrigger> |
|
||||||
<PopoverContent className="w-96 h-[450px] p-0"> |
|
||||||
<ScrollArea className="h-full"> |
|
||||||
<div className="p-4"> |
|
||||||
<RelaySettings /> |
|
||||||
</div> |
|
||||||
</ScrollArea> |
|
||||||
</PopoverContent> |
|
||||||
</Popover> |
|
||||||
</div> |
|
||||||
</PrimaryPageLayout> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
return ( |
||||||
<PrimaryPageLayout ref={layoutRef}> |
<PrimaryPageLayout |
||||||
<NoteList relayUrls={relayUrls} /> |
pageName="home" |
||||||
|
ref={layoutRef} |
||||||
|
titlebar={<NoteListPageTitlebar />} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
{!!urls.length && (feedType === 'relays' || (relayList && followings)) ? ( |
||||||
|
<NoteList |
||||||
|
relayUrls={urls} |
||||||
|
filter={ |
||||||
|
feedType === 'following' |
||||||
|
? { |
||||||
|
authors: |
||||||
|
pubkey && !followings?.includes(pubkey) |
||||||
|
? [...(followings ?? []), pubkey] |
||||||
|
: (followings ?? []) |
||||||
|
} |
||||||
|
: {} |
||||||
|
} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<div className="text-center text-sm text-muted-foreground">{t('loading...')}</div> |
||||||
|
)} |
||||||
</PrimaryPageLayout> |
</PrimaryPageLayout> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|
||||||
|
function NoteListPageTitlebar() { |
||||||
|
return ( |
||||||
|
<div className="flex gap-1 items-center h-full justify-between"> |
||||||
|
<FeedButton /> |
||||||
|
<SearchButton /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|||||||
@ -0,0 +1,29 @@ |
|||||||
|
import NotificationList from '@/components/NotificationList' |
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||||
|
import { Bell } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function NotificationListPage() { |
||||||
|
return ( |
||||||
|
<PrimaryPageLayout |
||||||
|
pageName="notifications" |
||||||
|
titlebar={<NotificationListPageTitlebar />} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<div className="px-4"> |
||||||
|
<NotificationList /> |
||||||
|
</div> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function NotificationListPageTitlebar() { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex gap-2 items-center h-full pl-3"> |
||||||
|
<Bell /> |
||||||
|
<div className="text-lg font-semibold">{t('Notifications')}</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,15 +0,0 @@ |
|||||||
import NotificationList from '@/components/NotificationList' |
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function NotificationListPage() { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
return ( |
|
||||||
<SecondaryPageLayout titlebarContent={t('notifications')}> |
|
||||||
<div className="max-sm:px-4"> |
|
||||||
<NotificationList /> |
|
||||||
</div> |
|
||||||
</SecondaryPageLayout> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import AboutInfoDialog from '@/components/AboutInfoDialog' |
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' |
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||||
|
import { useTheme } from '@/providers/ThemeProvider' |
||||||
|
import { TLanguage } from '@/types' |
||||||
|
import { SelectValue } from '@radix-ui/react-select' |
||||||
|
import { ChevronRight, Info, Languages, SunMoon } from 'lucide-react' |
||||||
|
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function SettingsPage({ index }: { index?: number }) { |
||||||
|
const { t, i18n } = useTranslation() |
||||||
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage) |
||||||
|
const { themeSetting, setThemeSetting } = useTheme() |
||||||
|
|
||||||
|
const handleLanguageChange = (value: TLanguage) => { |
||||||
|
i18n.changeLanguage(value) |
||||||
|
setLanguage(value) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}> |
||||||
|
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0"> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Languages /> |
||||||
|
<div>{t('Languages')}</div> |
||||||
|
</div> |
||||||
|
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}> |
||||||
|
<SelectTrigger className="w-32"> |
||||||
|
<SelectValue /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent> |
||||||
|
<SelectItem value="en">{t('English')}</SelectItem> |
||||||
|
<SelectItem value="zh">{t('Chinese')}</SelectItem> |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
</div> |
||||||
|
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0"> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<SunMoon /> |
||||||
|
<div>{t('Theme')}</div> |
||||||
|
</div> |
||||||
|
<Select defaultValue="system" value={themeSetting} onValueChange={setThemeSetting}> |
||||||
|
<SelectTrigger className="w-32"> |
||||||
|
<SelectValue /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent> |
||||||
|
<SelectItem value="system">{t('System')}</SelectItem> |
||||||
|
<SelectItem value="light">{t('Light')}</SelectItem> |
||||||
|
<SelectItem value="dark">{t('Dark')}</SelectItem> |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
</div> |
||||||
|
<AboutInfoDialog> |
||||||
|
<div className="flex clickable justify-between items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0"> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Info /> |
||||||
|
<div>{t('About')}</div> |
||||||
|
</div> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
v{__APP_VERSION__} ({__GIT_COMMIT__}) |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</AboutInfoDialog> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import { TFeedType } from '@/types' |
||||||
|
import { createContext, useContext, useState } from 'react' |
||||||
|
|
||||||
|
type TFeedContext = { |
||||||
|
feedType: TFeedType |
||||||
|
setFeedType: (feedType: TFeedType) => void |
||||||
|
} |
||||||
|
|
||||||
|
const FeedContext = createContext<TFeedContext | undefined>(undefined) |
||||||
|
|
||||||
|
export const useFeed = () => { |
||||||
|
const context = useContext(FeedContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error('useFeed must be used within a FeedProvider') |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
|
|
||||||
|
export function FeedProvider({ children }: { children: React.ReactNode }) { |
||||||
|
const [feedType, setFeedType] = useState<TFeedType>('relays') |
||||||
|
|
||||||
|
return <FeedContext.Provider value={{ feedType, setFeedType }}>{children}</FeedContext.Provider> |
||||||
|
} |
||||||
Loading…
Reference in new issue