feat: improve mobile experience
This commit is contained in:
parent
8ec0d46d58
commit
3946e603b3
98 changed files with 2508 additions and 1058 deletions
|
|
@ -1,48 +1,61 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="text-xl font-semibold">Jumble</div>
|
||||
<div className="text-muted-foreground">
|
||||
A beautiful nostr client focused on browsing relay feeds
|
||||
</div>
|
||||
<div>
|
||||
Made by{' '}
|
||||
<Username
|
||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||
className="inline-block text-primary"
|
||||
showAt
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
If you like this project, you can buy me a coffee ☕️ <br />
|
||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="p-4">{content}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Jumble</DialogTitle>
|
||||
<DialogDescription>
|
||||
A beautiful nostr client focused on browsing relay feeds
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
Made by{' '}
|
||||
<Username
|
||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||
className="inline-block text-primary"
|
||||
showAt
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
If you like this project, you can buy me a coffee ☕️ <br />
|
||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogContent>{content}</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,13 @@ import { formatPubkey } from '@/lib/pubkey'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TAccountPointer, TSignerType } from '@/types'
|
||||
import { Loader, Trash2 } from 'lucide-react'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
|
||||
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
||||
const { accounts, account, switchAccount } = useNostr()
|
||||
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||
|
||||
return (
|
||||
|
|
@ -20,9 +20,7 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
|||
key={`${act.pubkey}-${act.signerType}`}
|
||||
className={cn(
|
||||
'relative rounded-lg',
|
||||
isSameAccount(act, account)
|
||||
? 'border border-primary'
|
||||
: 'cursor-pointer hover:bg-muted/60'
|
||||
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isSameAccount(act, account)) return
|
||||
|
|
@ -44,14 +42,6 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
|||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<SignerTypeBadge signerType={act.signerType} />
|
||||
<Trash2
|
||||
size={16}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeAccount(act)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import AccountList from '../AccountList'
|
|||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
|
||||
export default function AccountManager({ close }: { close: () => void }) {
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||
)}
|
||||
|
|
@ -29,18 +29,18 @@ function AccountManagerNav({
|
|||
close
|
||||
}: {
|
||||
setLoginMethod: (method: TSignerType) => void
|
||||
close: () => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-4">
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => close())} className="w-full">
|
||||
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -56,9 +56,9 @@ function AccountManagerNav({
|
|||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Logged in Accounts')}
|
||||
</div>
|
||||
<AccountList afterSwitch={() => close()} />
|
||||
<AccountList afterSwitch={() => close?.()} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next'
|
|||
|
||||
export default function BackButton({
|
||||
hide = false,
|
||||
variant = 'titlebar'
|
||||
children
|
||||
}: {
|
||||
hide?: boolean
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
|
@ -16,8 +16,15 @@ export default function BackButton({
|
|||
return (
|
||||
<>
|
||||
{!hide && (
|
||||
<Button variant={variant} size={variant} title={t('back')} onClick={() => pop()}>
|
||||
<Button
|
||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
title={t('back')}
|
||||
onClick={() => pop()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
22
src/components/BottomNavigationBar/PostButton.tsx
Normal file
22
src/components/BottomNavigationBar/PostButton.tsx
Normal file
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
src/components/BottomNavigationBar/index.tsx
Normal file
25
src/components/BottomNavigationBar/index.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
103
src/components/FeedSwitcher/index.tsx
Normal file
103
src/components/FeedSwitcher/index.tsx
Normal file
|
|
@ -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" />
|
||||
)
|
||||
}
|
||||
|
|
@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
||||
const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||
|
||||
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
|
||||
if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null
|
||||
|
||||
const handleFollow = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Dispatch } from 'react'
|
||||
import AccountManager from '../AccountManager'
|
||||
|
||||
|
|
@ -15,6 +17,25 @@ export default function LoginDialog({
|
|||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent
|
||||
className="max-h-[90vh]"
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||
<AccountManager close={() => setOpen(false)} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96">
|
||||
|
|
|
|||
88
src/components/LogoutDialog/index.tsx
Normal file
88
src/components/LogoutDialog/index.tsx
Normal file
|
|
@ -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,3 +1,4 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { getParentEventId, getRootEventId } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
|
|
@ -32,11 +33,10 @@ export default function ShortTextNoteCard({
|
|||
push(toNote(event))
|
||||
}}
|
||||
>
|
||||
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
|
||||
<div
|
||||
className={`hover:bg-muted/50 text-left cursor-pointer ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3 sm:py-4 sm:border sm:rounded-lg max-sm:border-b'}`}
|
||||
className={`clickable text-left ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3'}`}
|
||||
>
|
||||
<RepostDescription reposter={reposter} className="sm:hidden" />
|
||||
<RepostDescription reposter={reposter} />
|
||||
<Note
|
||||
size={embedded ? 'small' : 'normal'}
|
||||
event={event}
|
||||
|
|
@ -44,6 +44,7 @@ export default function ShortTextNoteCard({
|
|||
hideStats={embedded}
|
||||
/>
|
||||
</div>
|
||||
{!embedded && <Separator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
|
|
@ -33,7 +31,7 @@ export default function NoteList({
|
|||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [displayReplies, setDisplayReplies] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const noteFilter = useMemo(() => {
|
||||
|
|
@ -48,16 +46,20 @@ export default function NoteList({
|
|||
if (isFetchingRelayInfo || relayUrls.length === 0) return
|
||||
|
||||
async function init() {
|
||||
setInitialized(false)
|
||||
setRefreshing(true)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
let eventCount = 0
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
[...relayUrls],
|
||||
noteFilter,
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
if (eventCount > events.length) return
|
||||
eventCount = events.length
|
||||
|
||||
if (events.length > 0) {
|
||||
setEvents(events)
|
||||
}
|
||||
|
|
@ -65,7 +67,7 @@ export default function NoteList({
|
|||
setHasMore(false)
|
||||
}
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
setRefreshing(false)
|
||||
setHasMore(events.length > 0)
|
||||
}
|
||||
},
|
||||
|
|
@ -100,7 +102,7 @@ export default function NoteList({
|
|||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
if (refreshing) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
|
|
@ -125,10 +127,10 @@ export default function NoteList({
|
|||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [initialized, hasMore, events, timelineKey])
|
||||
}, [refreshing, hasMore, events, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!timelineKey) return
|
||||
if (!timelineKey || refreshing) return
|
||||
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
|
|
@ -148,36 +150,35 @@ export default function NoteList({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 sm:space-y-4', className)}>
|
||||
<div className={cn('space-y-2 sm:space-y-2', className)}>
|
||||
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
|
||||
<PullToRefresh
|
||||
onRefresh={async () =>
|
||||
new Promise((resolve) => {
|
||||
setRefreshCount((pre) => pre + 1)
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
pullingContent=""
|
||||
>
|
||||
<div className="space-y-2 sm:space-y-4">
|
||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:gap-4">
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
{events
|
||||
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{hasMore ? (
|
||||
{hasMore || refreshing ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : events.length ? (
|
||||
t('no more notes')
|
||||
|
|
@ -201,38 +202,28 @@ function DisplayRepliesSwitch({
|
|||
setDisplayReplies: (value: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
||||
onClick={() => setDisplayReplies(false)}
|
||||
>
|
||||
{t('Notes')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setDisplayReplies(true)}
|
||||
>
|
||||
{t('Notes & Replies')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 px-4 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div>{t('Display replies')}</div>
|
||||
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} />
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
||||
onClick={() => setDisplayReplies(false)}
|
||||
>
|
||||
{t('Notes')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setDisplayReplies(true)}
|
||||
>
|
||||
{t('Notes & Replies')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 px-4 sm:px-6 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const { pubkey } = useNostr()
|
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
|
|
@ -18,7 +16,6 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||
<>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
||||
disabled={!pubkey}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
|
|
@ -28,7 +25,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||
<MessageCircle size={16} />
|
||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||
</button>
|
||||
<PostDialog parentEvent={event} open={open} setOpen={setOpen} />
|
||||
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import client from '@/services/client.service'
|
|||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ export default function RepostButton({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PostDialog
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function NoteStats({
|
|||
}) {
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className="flex gap-4 h-4 items-center">
|
||||
<div className="flex gap-4 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,18 +9,18 @@ import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
|||
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
export default function NotificationList() {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [notifications, setNotifications] = useState<Event[]>([])
|
||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -32,7 +32,9 @@ export default function NotificationList() {
|
|||
}
|
||||
|
||||
const init = async () => {
|
||||
setRefreshing(true)
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
let eventCount = 0
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayList.read.length >= 4
|
||||
? relayList.read
|
||||
|
|
@ -44,10 +46,12 @@ export default function NotificationList() {
|
|||
},
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||
if (eventCount > events.length) return
|
||||
eventCount = events.length
|
||||
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
|
||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
setRefreshing(false)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
|
|
@ -67,7 +71,7 @@ export default function NotificationList() {
|
|||
}, [pubkey, refreshCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
if (refreshing) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
|
|
@ -92,10 +96,10 @@ export default function NotificationList() {
|
|||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [until, initialized, timelineKey])
|
||||
}, [until, refreshing, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!pubkey || !timelineKey || !until) return
|
||||
if (!pubkey || !timelineKey || !until || refreshing) return
|
||||
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
if (notifications.length === 0) {
|
||||
setUntil(undefined)
|
||||
|
|
@ -111,12 +115,10 @@ export default function NotificationList() {
|
|||
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={async () =>
|
||||
new Promise((resolve) => {
|
||||
setRefreshCount((pre) => pre + 1)
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
|
|
@ -124,7 +126,11 @@ export default function NotificationList() {
|
|||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
|
||||
{until || refreshing ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : (
|
||||
t('no more notifications')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
174
src/components/PostEditor/PostContent.tsx
Normal file
174
src/components/PostEditor/PostContent.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
17
src/components/PostEditor/Title.tsx
Normal file
17
src/components/PostEditor/Title.tsx
Normal file
|
|
@ -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')
|
||||
)
|
||||
}
|
||||
78
src/components/PostEditor/index.tsx
Normal file
78
src/components/PostEditor/index.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
27
src/components/PubkeyCopy/index.tsx
Normal file
27
src/components/PubkeyCopy/index.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { formatNpub } from '@/lib/pubkey'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copyNpub = () => {
|
||||
if (!npub) return
|
||||
|
||||
navigator.clipboard.writeText(npub)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
|
||||
onClick={() => copyNpub()}
|
||||
>
|
||||
<div>{formatNpub(npub, 24)}</div>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/QrCodePopover/index.tsx
Normal file
43
src/components/QrCodePopover/index.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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 { 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>
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit h-fit">
|
||||
<QRCodeSVG value={`nostr:${npub}`} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ export default function RelaySettings({ hideTitle = false }: { hideTitle?: boole
|
|||
<RelayGroup key={index} group={group} />
|
||||
))}
|
||||
</div>
|
||||
{relayGroups.length < 5 && (
|
||||
{relayGroups.length < 10 && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="w-full border rounded-lg p-4">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import Content from '../Content'
|
|||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import LikeButton from '../NoteStats/LikeButton'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import PostDialog from '../PostDialog'
|
||||
import PostEditor from '../PostEditor'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ export default function ReplyNote({
|
|||
</div>
|
||||
</div>
|
||||
<LikeButton event={event} variant="reply" />
|
||||
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||
<PostEditor parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function ScrollToTopButton({
|
||||
|
|
@ -11,20 +12,35 @@ export default function ScrollToTopButton({
|
|||
className?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
if (isSmallScreen) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary-2"
|
||||
<div
|
||||
className={cn(
|
||||
`absolute bottom-6 right-6 rounded-full w-12 h-12 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-20'}`,
|
||||
`sticky z-20 flex justify-end pr-3 transition-opacity duration-700 ${visible ? '' : 'opacity-0'}`,
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToTop}
|
||||
style={{
|
||||
bottom: isSmallScreen
|
||||
? 'calc(env(safe-area-inset-bottom) + 3.75rem)'
|
||||
: 'calc(env(safe-area-inset-bottom) + 0.75rem)'
|
||||
}}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary-2"
|
||||
className="rounded-full w-12 h-12 p-0 hover:text-background"
|
||||
onClick={handleScrollToTop}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
src/components/Sidebar/AccountButton.tsx
Normal file
89
src/components/Sidebar/AccountButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
13
src/components/Sidebar/HomeButton.tsx
Normal file
13
src/components/Sidebar/HomeButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
17
src/components/Sidebar/NotificationButton.tsx
Normal file
17
src/components/Sidebar/NotificationButton.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
24
src/components/Sidebar/PostButton.tsx
Normal file
24
src/components/Sidebar/PostButton.tsx
Normal file
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/components/Sidebar/SearchButton.tsx
Normal file
24
src/components/Sidebar/SearchButton.tsx
Normal file
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
src/components/Sidebar/SidebarItem.tsx
Normal file
31
src/components/Sidebar/SidebarItem.tsx
Normal file
|
|
@ -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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ import { useTheme } from '@/providers/ThemeProvider'
|
|||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThemeToggle({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
export default function ThemeToggle() {
|
||||
const { t } = useTranslation()
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
|
||||
|
|
@ -15,8 +11,8 @@ export default function ThemeToggle({
|
|||
<>
|
||||
{themeSetting === 'system' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('light')}
|
||||
title={t('switch to light theme')}
|
||||
>
|
||||
|
|
@ -24,8 +20,8 @@ export default function ThemeToggle({
|
|||
</Button>
|
||||
) : themeSetting === 'light' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('dark')}
|
||||
title={t('switch to dark theme')}
|
||||
>
|
||||
|
|
@ -33,8 +29,8 @@ export default function ThemeToggle({
|
|||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('system')}
|
||||
title={t('switch to system theme')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function Titlebar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 w-full h-9 max-sm:h-11 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold gap-1 px-2 duration-700 transition-transform',
|
||||
'sticky top-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
visible ? '' : '-translate-y-full',
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useMemo } from 'react'
|
|||
|
||||
const UserAvatarSizeCnMap = {
|
||||
large: 'w-24 h-24',
|
||||
big: 'w-16 h-16',
|
||||
normal: 'w-10 h-10',
|
||||
small: 'w-7 h-7',
|
||||
tiny: 'w-4 h-4'
|
||||
|
|
@ -23,7 +24,7 @@ export default function UserAvatar({
|
|||
}: {
|
||||
userId: string
|
||||
className?: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
|
|
@ -62,7 +63,7 @@ export function SimpleUserAvatar({
|
|||
onClick
|
||||
}: {
|
||||
userId: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Image } from '@nextui-org/image'
|
||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function WebPreview({
|
||||
|
|
@ -12,6 +13,7 @@ export default function WebPreview({
|
|||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { title, description, image } = useFetchWebMetadata(url)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
|
|
@ -25,9 +27,21 @@ export default function WebPreview({
|
|||
return null
|
||||
}
|
||||
|
||||
if (isSmallScreen && image) {
|
||||
return (
|
||||
<div className="relative border rounded-lg w-full h-44">
|
||||
<Image src={image} className="rounded-lg object-cover w-full h-full" removeWrapper />
|
||||
<div className="absolute bottom-0 z-10 bg-muted/70 px-2 py-1 w-full rounded-b-lg">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
<div className="font-semibold line-clamp-1">{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)}
|
||||
className={cn('p-0 clickable flex w-full border rounded-lg', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank')
|
||||
|
|
@ -36,11 +50,11 @@ export default function WebPreview({
|
|||
{image && (
|
||||
<Image
|
||||
src={image}
|
||||
className={`rounded-l-lg object-cover w-2/5 ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||
className={`rounded-l-lg object-cover ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||
removeWrapper
|
||||
/>
|
||||
)}
|
||||
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}>
|
||||
<div className="flex-1 w-0 p-2">
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
|
||||
{title}
|
||||
|
|
|
|||
121
src/components/ui/alert-dialog.tsx
Normal file
121
src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -15,20 +15,15 @@ const buttonVariants = cva(
|
|||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
titlebar: 'hover:bg-accent hover:text-accent-foreground',
|
||||
sidebar: 'hover:bg-accent hover:text-accent-foreground',
|
||||
'small-screen-titlebar': 'hover:bg-accent hover:text-accent-foreground'
|
||||
ghost: 'clickable hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
titlebar: 'h-7 w-7 rounded-full',
|
||||
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold',
|
||||
'small-screen-titlebar': 'h-8 w-8 rounded-full'
|
||||
'titlebar-icon': 'h-10 w-10 rounded-lg'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
101
src/components/ui/drawer.tsx
Normal file
101
src/components/ui/drawer.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,23 +1,18 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
|
|
|
|||
150
src/components/ui/select.tsx
Normal file
150
src/components/ui/select.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
119
src/components/ui/sheet.tsx
Normal file
119
src/components/ui/sheet.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue