feat: improve mobile experience

This commit is contained in:
codytseng 2025-01-02 21:57:14 +08:00
parent 8ec0d46d58
commit 3946e603b3
98 changed files with 2508 additions and 1058 deletions

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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} />
}
}

View file

@ -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) && (

View file

@ -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>
)
}

View file

@ -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>
)}
</>

View 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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
</>
)
}

View 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>
)
}

View 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" />
)
}

View file

@ -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()

View file

@ -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">

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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} />
</>
)
}

View file

@ -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)}

View file

@ -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} />

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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} />
</>
)
}

View file

@ -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>
)
}

View 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>
)
}

View 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')
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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">

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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} />
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
</>
)
}

View 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} />
</>
)
}

View 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

View file

@ -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>
)
}

View file

@ -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')}
>

View file

@ -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
)}

View file

@ -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
}) {

View file

@ -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}

View 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
}

View file

@ -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: {

View 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
}

View file

@ -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

View 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
View 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
}