98 changed files with 2503 additions and 1053 deletions
@ -0,0 +1,24 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,13 +1,33 @@
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' |
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import { QrCode } from 'lucide-react' |
||||
import { nip19 } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { QRCodeSVG } from 'qrcode.react' |
||||
import { useMemo } from 'react' |
||||
|
||||
export default function QrCodePopover({ pubkey }: { pubkey: string }) { |
||||
const { isSmallScreen } = useScreenSize() |
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) |
||||
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 ( |
||||
<Popover> |
||||
<PopoverTrigger> |
||||
@ -1,19 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,35 +1,25 @@
|
||||
import Icon from '@/assets/Icon' |
||||
import Logo from '@/assets/Logo' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Info } from 'lucide-react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import AboutInfoDialog from '../AboutInfoDialog' |
||||
import AccountButton from '../AccountButton' |
||||
import NotificationButton from '../NotificationButton' |
||||
import PostButton from '../PostButton' |
||||
import RelaySettingsButton from '../RelaySettingsButton' |
||||
import SearchButton from '../SearchButton' |
||||
import AccountButton from './AccountButton' |
||||
import HomeButton from './HomeButton' |
||||
import NotificationsButton from './NotificationButton' |
||||
import PostButton from './PostButton' |
||||
import SearchButton from './SearchButton' |
||||
|
||||
export default function PrimaryPageSidebar() { |
||||
const { t } = useTranslation() |
||||
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="absolute top-0 left-0 h-11 w-full" /> |
||||
<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="space-y-2"> |
||||
<div className="ml-4 mb-8 w-40"> |
||||
<Logo /> |
||||
<div className="px-2 mb-10 w-full"> |
||||
<Icon className="xl:hidden" /> |
||||
<Logo className="max-xl:hidden" /> |
||||
</div> |
||||
<PostButton variant="sidebar" /> |
||||
<RelaySettingsButton variant="sidebar" /> |
||||
<NotificationButton variant="sidebar" /> |
||||
<SearchButton variant="sidebar" /> |
||||
<AboutInfoDialog> |
||||
<Button variant="sidebar" size="sidebar"> |
||||
<Info /> |
||||
{t('About')} |
||||
</Button> |
||||
</AboutInfoDialog> |
||||
<HomeButton /> |
||||
<NotificationsButton /> |
||||
<SearchButton /> |
||||
<PostButton /> |
||||
</div> |
||||
<AccountButton variant="sidebar" /> |
||||
<AccountButton /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,121 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,48 +1,69 @@
|
||||
import NoteList from '@/components/NoteList' |
||||
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 { BIG_RELAY_URLS } from '@/constants' |
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||
import { useFeed } from '@/providers/FeedProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
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() { |
||||
const { t } = useTranslation() |
||||
const layoutRef = useRef<{ scrollToTop: () => void }>(null) |
||||
const { relayUrls } = useRelaySettings() |
||||
const relayUrlsString = JSON.stringify(relayUrls) |
||||
const { feedType } = useFeed() |
||||
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(() => { |
||||
if (layoutRef.current) { |
||||
layoutRef.current.scrollToTop() |
||||
} |
||||
}, [relayUrlsString]) |
||||
|
||||
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> |
||||
) |
||||
} |
||||
}, [JSON.stringify(relayUrls), feedType]) |
||||
|
||||
return ( |
||||
<PrimaryPageLayout ref={layoutRef}> |
||||
<NoteList relayUrls={relayUrls} /> |
||||
<PrimaryPageLayout |
||||
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> |
||||
) |
||||
} |
||||
|
||||
function NoteListPageTitlebar() { |
||||
return ( |
||||
<div className="flex gap-1 items-center h-full justify-between"> |
||||
<FeedButton /> |
||||
<SearchButton /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,29 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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