@@ -1,15 +1,18 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { truncateUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ExternalLink({
|
||||
@@ -22,9 +25,29 @@ export default function ExternalLink({
|
||||
justOpenLink?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const displayUrl = useMemo(() => truncateUrl(url), [url])
|
||||
|
||||
const handleOpenLink = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(false)
|
||||
}
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
const handleViewDiscussions = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(false)
|
||||
setTimeout(() => push(toExternalContent(url)), 100) // wait for drawer to close
|
||||
return
|
||||
}
|
||||
push(toExternalContent(url))
|
||||
}
|
||||
|
||||
if (justOpenLink) {
|
||||
return (
|
||||
<a
|
||||
@@ -39,38 +62,74 @@ export default function ExternalLink({
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenLink = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
const trigger = (
|
||||
<span
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
)
|
||||
|
||||
const handleViewDiscussions = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
setTimeout(() => push(toExternalContent(url)), 100) // wait for menu to close
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(false)
|
||||
}}
|
||||
/>
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={handleOpenLink}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleViewDiscussions}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-block" onClick={(e) => e.stopPropagation()}>
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<span
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</ResponsiveMenuTrigger>
|
||||
<ResponsiveMenuContent align="start">
|
||||
<ResponsiveMenuItem onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem onClick={handleViewDiscussions}>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<span className={cn('cursor-pointer text-primary hover:underline', className)} title={url}>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleViewDiscussions}>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DrawerMenuItem from '../DrawerMenuItem'
|
||||
import RelayUrls from './RelayUrl'
|
||||
import { useRelaySetsSettingComponent } from './provider'
|
||||
|
||||
@@ -136,9 +139,16 @@ function RelayUrlsExpandToggle({
|
||||
|
||||
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { deleteRelaySet } = useFavoriteRelays()
|
||||
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
)
|
||||
|
||||
const rename = () => {
|
||||
setRenamingRelaySetId(relaySet.id)
|
||||
}
|
||||
@@ -149,30 +159,53 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="py-2">
|
||||
<DrawerMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</DrawerMenuItem>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={rename}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem onClick={copyShareLink}>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { BellOff, Loader } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -14,6 +16,7 @@ import { toast } from 'sonner'
|
||||
|
||||
export default function MuteButton({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
|
||||
useMuteList()
|
||||
@@ -71,34 +74,63 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
disabled={updating || changing}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Mute')}
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
disabled={updating || changing}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Mute')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
|
||||
variant="ghost"
|
||||
onClick={(e) => handleMute(e, true)}
|
||||
disabled={updating || changing}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Mute user privately')}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full p-6 justify-start text-destructive text-lg gap-4 [&_svg]:size-5 focus:text-destructive"
|
||||
variant="ghost"
|
||||
onClick={(e) => handleMute(e, false)}
|
||||
disabled={updating || changing}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Mute user publicly')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleMute(e, true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user privately')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleMute(e, false)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user publicly')}
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
64
src/components/NoteOptions/DesktopMenu.tsx
Normal file
64
src/components/NoteOptions/DesktopMenu.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MenuAction } from './useMenuActions'
|
||||
|
||||
interface DesktopMenuProps {
|
||||
menuActions: MenuAction[]
|
||||
trigger: React.ReactNode
|
||||
}
|
||||
|
||||
export function DesktopMenu({ menuActions, trigger }: DesktopMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto">
|
||||
{menuActions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<div key={index}>
|
||||
{action.separator && index > 0 && <DropdownMenuSeparator />}
|
||||
{action.subMenu ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={action.className}>
|
||||
<Icon />
|
||||
{action.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="max-h-[50vh] overflow-y-auto"
|
||||
showScrollButtons
|
||||
>
|
||||
{action.subMenu.map((subAction, subIndex) => (
|
||||
<div key={subIndex}>
|
||||
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onClick={subAction.onClick}
|
||||
className={cn('w-64', subAction.className)}
|
||||
>
|
||||
{subAction.label}
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={action.onClick} className={action.className}>
|
||||
<Icon />
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
79
src/components/NoteOptions/MobileMenu.tsx
Normal file
79
src/components/NoteOptions/MobileMenu.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { MenuAction, SubMenuAction } from './useMenuActions'
|
||||
|
||||
interface MobileMenuProps {
|
||||
menuActions: MenuAction[]
|
||||
trigger: React.ReactNode
|
||||
isDrawerOpen: boolean
|
||||
setIsDrawerOpen: (open: boolean) => void
|
||||
showSubMenu: boolean
|
||||
activeSubMenu: SubMenuAction[]
|
||||
subMenuTitle: string
|
||||
closeDrawer: () => void
|
||||
goBackToMainMenu: () => void
|
||||
}
|
||||
|
||||
export function MobileMenu({
|
||||
menuActions,
|
||||
trigger,
|
||||
isDrawerOpen,
|
||||
setIsDrawerOpen,
|
||||
showSubMenu,
|
||||
activeSubMenu,
|
||||
subMenuTitle,
|
||||
closeDrawer,
|
||||
goBackToMainMenu
|
||||
}: MobileMenuProps) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={closeDrawer} />
|
||||
<DrawerContent hideOverlay className="max-h-[80vh]">
|
||||
<div className="overflow-y-auto overscroll-contain py-2" style={{ touchAction: 'pan-y' }}>
|
||||
{!showSubMenu ? (
|
||||
menuActions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
className={`w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 ${action.className || ''}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon />
|
||||
{action.label}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={goBackToMainMenu}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft />
|
||||
{subMenuTitle}
|
||||
</Button>
|
||||
<div className="border-t border-border mb-2" />
|
||||
{activeSubMenu.map((subAction, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={subAction.onClick}
|
||||
className={`w-full p-6 justify-start text-lg gap-4 ${subAction.className || ''}`}
|
||||
variant="ghost"
|
||||
>
|
||||
{subAction.label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +1,72 @@
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuSub,
|
||||
ResponsiveMenuSubContent,
|
||||
ResponsiveMenuSubTrigger,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { DesktopMenu } from './DesktopMenu'
|
||||
import { MobileMenu } from './MobileMenu'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
import ReportDialog from './ReportDialog'
|
||||
import { useMenuActions } from './useMenuActions'
|
||||
import { SubMenuAction, useMenuActions } from './useMenuActions'
|
||||
|
||||
export default function NoteOptions({ event, className }: { event: Event; className?: string }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const [showSubMenu, setShowSubMenu] = useState(false)
|
||||
const [activeSubMenu, setActiveSubMenu] = useState<SubMenuAction[]>([])
|
||||
const [subMenuTitle, setSubMenuTitle] = useState('')
|
||||
|
||||
const closeDrawer = () => {
|
||||
setIsDrawerOpen(false)
|
||||
setShowSubMenu(false)
|
||||
}
|
||||
|
||||
const goBackToMainMenu = () => {
|
||||
setShowSubMenu(false)
|
||||
}
|
||||
|
||||
const showSubMenuActions = (subMenu: SubMenuAction[], title: string) => {
|
||||
setActiveSubMenu(subMenu)
|
||||
setSubMenuTitle(title)
|
||||
setShowSubMenu(true)
|
||||
}
|
||||
|
||||
const menuActions = useMenuActions({
|
||||
event,
|
||||
closeDrawer,
|
||||
showSubMenuActions,
|
||||
setIsRawEventDialogOpen,
|
||||
setIsReportDialogOpen
|
||||
setIsReportDialogOpen,
|
||||
isSmallScreen
|
||||
})
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex items-center text-muted-foreground hover:text-foreground pl-2 h-full"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
>
|
||||
<Ellipsis />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className} onClick={(e) => e.stopPropagation()}>
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<button className="flex items-center text-muted-foreground hover:text-foreground pl-2 h-full">
|
||||
<Ellipsis />
|
||||
</button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent showScrollButtons>
|
||||
{menuActions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<div key={index}>
|
||||
{action.separator && index > 0 && <ResponsiveMenuSeparator />}
|
||||
{action.subMenu ? (
|
||||
<ResponsiveMenuSub>
|
||||
<ResponsiveMenuSubTrigger className={action.className}>
|
||||
<Icon />
|
||||
{action.label}
|
||||
</ResponsiveMenuSubTrigger>
|
||||
<ResponsiveMenuSubContent showScrollButtons>
|
||||
{action.subMenu.map((subAction, subIndex) => (
|
||||
<div key={subIndex}>
|
||||
{subAction.separator && subIndex > 0 && <ResponsiveMenuSeparator />}
|
||||
<ResponsiveMenuItem
|
||||
onClick={subAction.onClick}
|
||||
className={subAction.className}
|
||||
>
|
||||
{subAction.label}
|
||||
</ResponsiveMenuItem>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveMenuSubContent>
|
||||
</ResponsiveMenuSub>
|
||||
) : (
|
||||
<ResponsiveMenuItem onClick={action.onClick} className={action.className}>
|
||||
<Icon />
|
||||
{action.label}
|
||||
</ResponsiveMenuItem>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
{isSmallScreen ? (
|
||||
<MobileMenu
|
||||
menuActions={menuActions}
|
||||
trigger={trigger}
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
setIsDrawerOpen={setIsDrawerOpen}
|
||||
showSubMenu={showSubMenu}
|
||||
activeSubMenu={activeSubMenu}
|
||||
subMenuTitle={subMenuTitle}
|
||||
closeDrawer={closeDrawer}
|
||||
goBackToMainMenu={goBackToMainMenu}
|
||||
/>
|
||||
) : (
|
||||
<DesktopMenu menuActions={menuActions} trigger={trigger} />
|
||||
)}
|
||||
|
||||
<RawEventDialog
|
||||
event={event}
|
||||
|
||||
@@ -44,14 +44,20 @@ export interface MenuAction {
|
||||
|
||||
interface UseMenuActionsProps {
|
||||
event: Event
|
||||
closeDrawer: () => void
|
||||
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
|
||||
setIsRawEventDialogOpen: (open: boolean) => void
|
||||
setIsReportDialogOpen: (open: boolean) => void
|
||||
isSmallScreen: boolean
|
||||
}
|
||||
|
||||
export function useMenuActions({
|
||||
event,
|
||||
closeDrawer,
|
||||
showSubMenuActions,
|
||||
setIsRawEventDialogOpen,
|
||||
setIsReportDialogOpen
|
||||
setIsReportDialogOpen,
|
||||
isSmallScreen
|
||||
}: UseMenuActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, attemptDelete } = useNostr()
|
||||
@@ -70,6 +76,7 @@ export function useMenuActions({
|
||||
items.push({
|
||||
label: <div className="text-left"> {t('Optimal relays')}</div>,
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
const promise = async () => {
|
||||
const relays = await client.determineTargetRelays(event)
|
||||
if (relays?.length) {
|
||||
@@ -100,6 +107,7 @@ export function useMenuActions({
|
||||
.map((set, index) => ({
|
||||
label: <div className="text-left truncate">{set.name}</div>,
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
const promise = client.publishEvent(set.relayUrls, event)
|
||||
toast.promise(promise, {
|
||||
loading: t('Republishing...'),
|
||||
@@ -129,6 +137,7 @@ export function useMenuActions({
|
||||
</div>
|
||||
),
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
const promise = client.publishEvent([relay], event)
|
||||
toast.promise(promise, {
|
||||
loading: t('Republishing...'),
|
||||
@@ -149,7 +158,7 @@ export function useMenuActions({
|
||||
}
|
||||
|
||||
return items
|
||||
}, [pubkey, relayUrls, relaySets, event, t])
|
||||
}, [pubkey, relayUrls, relaySets])
|
||||
|
||||
const menuActions: MenuAction[] = useMemo(() => {
|
||||
const actions: MenuAction[] = [
|
||||
@@ -158,6 +167,7 @@ export function useMenuActions({
|
||||
label: t('Copy event ID'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(getNoteBech32Id(event))
|
||||
closeDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -165,6 +175,7 @@ export function useMenuActions({
|
||||
label: t('Copy user ID'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
|
||||
closeDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -172,12 +183,14 @@ export function useMenuActions({
|
||||
label: t('Copy share link'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
|
||||
closeDrawer()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
label: t('View raw event'),
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
setIsRawEventDialogOpen(true)
|
||||
},
|
||||
separator: true
|
||||
@@ -189,7 +202,10 @@ export function useMenuActions({
|
||||
actions.push({
|
||||
icon: SatelliteDish,
|
||||
label: t('Republish to ...'),
|
||||
subMenu: broadcastSubMenu,
|
||||
onClick: isSmallScreen
|
||||
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
|
||||
: undefined,
|
||||
subMenu: isSmallScreen ? undefined : broadcastSubMenu,
|
||||
separator: true
|
||||
})
|
||||
}
|
||||
@@ -200,6 +216,7 @@ export function useMenuActions({
|
||||
icon: pinned ? PinOff : Pin,
|
||||
label: pinned ? t('Unpin from profile') : t('Pin to profile'),
|
||||
onClick: async () => {
|
||||
closeDrawer()
|
||||
await (pinned ? unpin(event) : pin(event))
|
||||
}
|
||||
})
|
||||
@@ -211,6 +228,7 @@ export function useMenuActions({
|
||||
label: t('Report'),
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
setIsReportDialogOpen(true)
|
||||
},
|
||||
separator: true
|
||||
@@ -223,6 +241,7 @@ export function useMenuActions({
|
||||
icon: Bell,
|
||||
label: t('Unmute user'),
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
unmutePubkey(event.pubkey)
|
||||
},
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
@@ -234,6 +253,7 @@ export function useMenuActions({
|
||||
icon: BellOff,
|
||||
label: t('Mute user privately'),
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
mutePubkeyPrivately(event.pubkey)
|
||||
},
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
@@ -243,6 +263,7 @@ export function useMenuActions({
|
||||
icon: BellOff,
|
||||
label: t('Mute user publicly'),
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
mutePubkeyPublicly(event.pubkey)
|
||||
},
|
||||
className: 'text-destructive focus:text-destructive'
|
||||
@@ -256,6 +277,7 @@ export function useMenuActions({
|
||||
icon: Trash2,
|
||||
label: t('Try deleting this note'),
|
||||
onClick: () => {
|
||||
closeDrawer()
|
||||
attemptDelete(event)
|
||||
},
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
@@ -269,16 +291,15 @@ export function useMenuActions({
|
||||
event,
|
||||
pubkey,
|
||||
isMuted,
|
||||
isSmallScreen,
|
||||
broadcastSubMenu,
|
||||
pinnedEventHexIdSet,
|
||||
closeDrawer,
|
||||
showSubMenuActions,
|
||||
setIsRawEventDialogOpen,
|
||||
setIsReportDialogOpen,
|
||||
mutePubkeyPrivately,
|
||||
mutePubkeyPublicly,
|
||||
unmutePubkey,
|
||||
unpin,
|
||||
pin,
|
||||
attemptDelete
|
||||
unmutePubkey
|
||||
])
|
||||
|
||||
return menuActions
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuCheckboxItem,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Event, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
@@ -26,6 +30,8 @@ export default function Mentions({
|
||||
parentEvent?: Event
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { pubkey } = useNostr()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const [potentialMentions, setPotentialMentions] = useState<string[]>([])
|
||||
@@ -58,11 +64,69 @@ export default function Mentions({
|
||||
useEffect(() => {
|
||||
const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey))
|
||||
setMentions(newMentions)
|
||||
}, [potentialMentions, removedPubkeys, setMentions])
|
||||
}, [potentialMentions, removedPubkeys])
|
||||
|
||||
const items = useMemo(() => {
|
||||
return potentialMentions.map((_, index) => {
|
||||
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
|
||||
const isParentPubkey = pubkey === parentEventPubkey
|
||||
return (
|
||||
<MenuItem
|
||||
key={`${pubkey}-${index}`}
|
||||
checked={isParentPubkey ? true : mentions.includes(pubkey)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isParentPubkey) {
|
||||
return
|
||||
}
|
||||
if (checked) {
|
||||
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
|
||||
} else {
|
||||
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
|
||||
}
|
||||
}}
|
||||
disabled={isParentPubkey}
|
||||
>
|
||||
<SimpleUserAvatar userId={pubkey} size="small" />
|
||||
<SimpleUsername
|
||||
userId={pubkey}
|
||||
className="font-semibold text-sm truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
}, [potentialMentions, parentEventPubkey, mentions])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="px-3"
|
||||
variant="ghost"
|
||||
disabled={potentialMentions.length === 0}
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
>
|
||||
{t('Mentions')}{' '}
|
||||
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
|
||||
</Button>
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent className="max-h-[80vh]" hideOverlay>
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain py-2"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
{items}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="px-3"
|
||||
variant="ghost"
|
||||
@@ -72,38 +136,57 @@ export default function Mentions({
|
||||
{t('Mentions')}{' '}
|
||||
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
<ResponsiveMenuContent align="start" className="max-w-96" showScrollButtons>
|
||||
{potentialMentions.map((_, index) => {
|
||||
const pubkey = potentialMentions[potentialMentions.length - 1 - index]
|
||||
const isParentPubkey = pubkey === parentEventPubkey
|
||||
return (
|
||||
<ResponsiveMenuCheckboxItem
|
||||
key={`${pubkey}-${index}`}
|
||||
checked={isParentPubkey ? true : mentions.includes(pubkey)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (isParentPubkey) {
|
||||
return
|
||||
}
|
||||
if (checked) {
|
||||
setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey))
|
||||
} else {
|
||||
setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey])
|
||||
}
|
||||
}}
|
||||
disabled={isParentPubkey}
|
||||
>
|
||||
<SimpleUserAvatar userId={pubkey} size="small" />
|
||||
<SimpleUsername
|
||||
userId={pubkey}
|
||||
className="font-semibold text-sm truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
</ResponsiveMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
|
||||
{items}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
children,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
onCheckedChange(!checked)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-3 clickable',
|
||||
disabled ? 'opacity-50 pointer-events-none' : ''
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center size-4 shrink-0">
|
||||
{checked && <Check className="size-4" />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuCheckboxItem,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { isProtectedEvent } from '@/lib/event'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Check } from 'lucide-react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -43,6 +47,8 @@ export default function PostRelaySelector({
|
||||
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { relayUrls } = useCurrentRelays()
|
||||
const { relaySets, favoriteRelays } = useFavoriteRelays()
|
||||
const [postTargetItems, setPostTargetItems] = useState<TPostTargetItem[]>([])
|
||||
@@ -73,10 +79,8 @@ export default function PostRelaySelector({
|
||||
: simplifyUrl(item.urls[0])
|
||||
}
|
||||
}
|
||||
const hasWriteRelays = postTargetItems.some(
|
||||
(item: TPostTargetItem) => item.type === 'writeRelays'
|
||||
)
|
||||
const relayCount = postTargetItems.reduce((count: number, item: TPostTargetItem) => {
|
||||
const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays')
|
||||
const relayCount = postTargetItems.reduce((count, item) => {
|
||||
if (item.type === 'relay') {
|
||||
return count + 1
|
||||
}
|
||||
@@ -89,7 +93,7 @@ export default function PostRelaySelector({
|
||||
return t('Optimal relays and {{count}} other relays', { count: relayCount })
|
||||
}
|
||||
return t('{{count}} relays', { count: relayCount })
|
||||
}, [postTargetItems, t])
|
||||
}, [postTargetItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (openFrom && openFrom.length) {
|
||||
@@ -104,10 +108,8 @@ export default function PostRelaySelector({
|
||||
}, [openFrom, parentEventSeenOnRelays])
|
||||
|
||||
useEffect(() => {
|
||||
const isProtected = postTargetItems.every(
|
||||
(item: TPostTargetItem) => item.type !== 'writeRelays'
|
||||
)
|
||||
const relayUrls = postTargetItems.flatMap((item: TPostTargetItem) => {
|
||||
const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays')
|
||||
const relayUrls = postTargetItems.flatMap((item) => {
|
||||
if (item.type === 'relay') {
|
||||
return [item.url]
|
||||
}
|
||||
@@ -117,26 +119,24 @@ export default function PostRelaySelector({
|
||||
return []
|
||||
})
|
||||
|
||||
setIsProtectedEvent(isProtected)
|
||||
setIsProtectedEvent(isProtectedEvent)
|
||||
setAdditionalRelayUrls(relayUrls)
|
||||
}, [postTargetItems, setIsProtectedEvent, setAdditionalRelayUrls])
|
||||
}, [postTargetItems])
|
||||
|
||||
const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'writeRelays' }])
|
||||
setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }])
|
||||
} else {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) =>
|
||||
prev.filter((item: TPostTargetItem) => item.type !== 'writeRelays')
|
||||
)
|
||||
setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relay', url }])
|
||||
setPostTargetItems((prev) => [...prev, { type: 'relay', url }])
|
||||
} else {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) =>
|
||||
prev.filter((item: TPostTargetItem) => !(item.type === 'relay' && item.url === url))
|
||||
setPostTargetItems((prev) =>
|
||||
prev.filter((item) => !(item.type === 'relay' && item.url === url))
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
@@ -144,74 +144,152 @@ export default function PostRelaySelector({
|
||||
const handleRelaySetCheckedChange = useCallback(
|
||||
(checked: boolean, id: string, urls: string[]) => {
|
||||
if (checked) {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relaySet', id, urls }])
|
||||
setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }])
|
||||
} else {
|
||||
setPostTargetItems((prev: TPostTargetItem[]) =>
|
||||
prev.filter((item: TPostTargetItem) => !(item.type === 'relaySet' && item.id === id))
|
||||
setPostTargetItems((prev) =>
|
||||
prev.filter((item) => !(item.type === 'relaySet' && item.id === id))
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<Label>{t('Post to')}</Label>
|
||||
<Button variant="outline" className="px-2 flex-1 max-w-fit justify-start">
|
||||
<div className="truncate">{description}</div>
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent align="start" className="max-w-96" showScrollButtons>
|
||||
<ResponsiveMenuCheckboxItem
|
||||
checked={postTargetItems.some((item: TPostTargetItem) => item.type === 'writeRelays')}
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
checked={postTargetItems.some((item) => item.type === 'writeRelays')}
|
||||
onCheckedChange={handleWriteRelaysCheckedChange}
|
||||
>
|
||||
{t('Write relays')}
|
||||
</ResponsiveMenuCheckboxItem>
|
||||
</MenuItem>
|
||||
{relaySets.length > 0 && (
|
||||
<>
|
||||
<ResponsiveMenuSeparator />
|
||||
<MenuSeparator />
|
||||
{relaySets
|
||||
.filter(({ relayUrls }) => relayUrls.length)
|
||||
.map(({ id, name, relayUrls }) => (
|
||||
<ResponsiveMenuCheckboxItem
|
||||
<MenuItem
|
||||
key={id}
|
||||
checked={postTargetItems.some(
|
||||
(item: TPostTargetItem) => item.type === 'relaySet' && item.id === id
|
||||
(item) => item.type === 'relaySet' && item.id === id
|
||||
)}
|
||||
onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)}
|
||||
>
|
||||
<div className="truncate">
|
||||
{name} ({relayUrls.length})
|
||||
</div>
|
||||
</ResponsiveMenuCheckboxItem>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{selectableRelays.length > 0 && (
|
||||
<>
|
||||
<ResponsiveMenuSeparator />
|
||||
<MenuSeparator />
|
||||
{selectableRelays.map((url) => (
|
||||
<ResponsiveMenuCheckboxItem
|
||||
<MenuItem
|
||||
key={url}
|
||||
checked={postTargetItems.some(
|
||||
(item: TPostTargetItem) => item.type === 'relay' && item.url === url
|
||||
)}
|
||||
checked={postTargetItems.some((item) => item.type === 'relay' && item.url === url)}
|
||||
onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RelayIcon url={url} />
|
||||
<div className="truncate">{simplifyUrl(url)}</div>
|
||||
</div>
|
||||
</ResponsiveMenuCheckboxItem>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</>
|
||||
)
|
||||
}, [postTargetItems, relaySets, selectableRelays])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t('Post to')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2 flex-1 max-w-fit justify-start"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
>
|
||||
<div className="truncate">{description}</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent className="max-h-[80vh]" hideOverlay>
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain py-2"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t('Post to')}</Label>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="px-2 flex-1 max-w-fit justify-start">
|
||||
<div className="truncate">{description}</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent align="start" className="max-w-96 max-h-[50vh]" showScrollButtons>
|
||||
{content}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuSeparator() {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
if (isSmallScreen) {
|
||||
return <Separator />
|
||||
}
|
||||
return <DropdownMenuSeparator />
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
children,
|
||||
checked,
|
||||
onCheckedChange
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className="flex items-center gap-2 px-4 py-3 clickable"
|
||||
>
|
||||
<div className="flex items-center justify-center size-4 shrink-0">
|
||||
{checked && <Check className="size-4" />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={checked}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,67 +1,147 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Bell, BellOff, Copy, Ellipsis } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
|
||||
|
||||
if (pubkey === accountPubkey) return null
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => {
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
)
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
{t('Copy user ID')}
|
||||
</Button>
|
||||
{accountPubkey ? (
|
||||
isMuted ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
unmutePubkey(pubkey)
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
|
||||
variant="ghost"
|
||||
>
|
||||
<Bell />
|
||||
{t('Unmute user')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
mutePubkeyPrivately(pubkey)
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
|
||||
variant="ghost"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user privately')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
mutePubkeyPublicly(pubkey)
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 text-destructive focus:text-destructive"
|
||||
variant="ghost"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user publicly')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
|
||||
<Copy />
|
||||
{t('Copy user ID')}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
{accountPubkey ? (
|
||||
isMuted ? (
|
||||
<ResponsiveMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => unmutePubkey(pubkey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Bell />
|
||||
{t('Unmute user')}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => mutePubkeyPrivately(pubkey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user privately')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => mutePubkeyPublicly(pubkey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<BellOff />
|
||||
{t('Mute user publicly')}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuLabel,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerTitle
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { Check, FolderPlus, Plus, Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DrawerMenuItem from '../DrawerMenuItem'
|
||||
|
||||
export default function SaveRelayDropdownMenu({
|
||||
urls,
|
||||
@@ -23,6 +33,7 @@ export default function SaveRelayDropdownMenu({
|
||||
bigButton?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { favoriteRelays, relaySets } = useFavoriteRelays()
|
||||
const normalizedUrls = useMemo(() => urls.map((url) => normalizeUrl(url)).filter(Boolean), [urls])
|
||||
const alreadySaved = useMemo(() => {
|
||||
@@ -30,39 +41,73 @@ export default function SaveRelayDropdownMenu({
|
||||
normalizedUrls.every((url) => favoriteRelays.includes(url)) ||
|
||||
relaySets.some((set) => normalizedUrls.every((url) => set.relayUrls.includes(url)))
|
||||
)
|
||||
}, [relaySets, normalizedUrls, favoriteRelays])
|
||||
}, [relaySets, normalizedUrls])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
const trigger = bigButton ? (
|
||||
<Button variant="ghost" size="titlebar-icon" onClick={() => setIsDrawerOpen(true)}>
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
className="enabled:hover:text-primary [&_svg]:size-5 pr-0 pt-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div>
|
||||
{trigger}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t('Save to')} ...</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="py-2">
|
||||
<RelayItem urls={normalizedUrls} />
|
||||
{relaySets.map((set) => (
|
||||
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
|
||||
))}
|
||||
<Separator />
|
||||
<SaveToNewSet urls={normalizedUrls} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
{bigButton ? (
|
||||
<Button variant="ghost" size="titlebar-icon">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button className="enabled:hover:text-primary [&_svg]:size-5 pr-0 pt-0.5">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</ResponsiveMenuTrigger>
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuLabel>{t('Save to')} ...</ResponsiveMenuLabel>
|
||||
<ResponsiveMenuSeparator />
|
||||
<RelayItem urls={normalizedUrls} />
|
||||
{relaySets.map((set) => (
|
||||
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
|
||||
))}
|
||||
<ResponsiveMenuSeparator />
|
||||
<SaveToNewSet urls={normalizedUrls} />
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="px-2">
|
||||
{trigger}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<RelayItem urls={normalizedUrls} />
|
||||
{relaySets.map((set) => (
|
||||
<RelaySetItem key={set.id} set={set} urls={normalizedUrls} />
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<SaveToNewSet urls={normalizedUrls} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayItem({ urls }: { urls: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { favoriteRelays, addFavoriteRelays, deleteFavoriteRelays } = useFavoriteRelays()
|
||||
const saved = useMemo(
|
||||
() => urls.every((url) => favoriteRelays.includes(url)),
|
||||
@@ -77,15 +122,25 @@ function RelayItem({ urls }: { urls: string[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{saved ? t('Unfavorite') : t('Favorite')}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenuItem onClick={handleClick}>
|
||||
<DropdownMenuItem className="flex gap-2" onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{saved ? t('Unfavorite') : t('Favorite')}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, startLogin } = useNostr()
|
||||
const { updateRelaySet } = useFavoriteRelays()
|
||||
const saved = urls.every((url) => set.relayUrls.includes(url))
|
||||
@@ -108,16 +163,26 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{set.name}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenuItem onClick={handleClick}>
|
||||
<DropdownMenuItem key={set.id} className="flex gap-2" onClick={handleClick}>
|
||||
{saved ? <Check /> : <Plus />}
|
||||
{set.name}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveToNewSet({ urls }: { urls: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, startLogin } = useNostr()
|
||||
const { createRelaySet } = useFavoriteRelays()
|
||||
|
||||
@@ -132,10 +197,19 @@ function SaveToNewSet({ urls }: { urls: string[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<DrawerMenuItem onClick={handleSave}>
|
||||
<FolderPlus />
|
||||
{t('Save to a new relay set')}
|
||||
</DrawerMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenuItem onClick={handleSave}>
|
||||
<DropdownMenuItem onClick={handleSave}>
|
||||
<FolderPlus />
|
||||
{t('Save to a new relay set')}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuLabel,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toWallet } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
@@ -41,8 +41,8 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
||||
if (!pubkey) return null
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
@@ -57,16 +57,16 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
<ResponsiveMenuContent side="top" className="w-72 max-h-[80vh]">
|
||||
<ResponsiveMenuItem onClick={() => push(toWallet())}>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="w-72">
|
||||
<DropdownMenuItem onClick={() => push(toWallet())}>
|
||||
<Wallet />
|
||||
{t('Wallet')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuSeparator />
|
||||
<ResponsiveMenuLabel>{t('Switch account')}</ResponsiveMenuLabel>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t('Switch account')}</DropdownMenuLabel>
|
||||
{accounts.map((act) => (
|
||||
<ResponsiveMenuItem
|
||||
<DropdownMenuItem
|
||||
className={act.pubkey === pubkey ? 'cursor-default focus:bg-background' : ''}
|
||||
key={`${act.pubkey}:${act.signerType}`}
|
||||
onClick={() => {
|
||||
@@ -92,17 +92,18 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
||||
act.pubkey === pubkey && 'size-4 border-4 border-primary'
|
||||
)}
|
||||
/>
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<div className="border border-dashed m-2 rounded-md">
|
||||
<ResponsiveMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||
<div className="flex gap-2 items-center justify-center w-full py-2">
|
||||
<Plus />
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
</ResponsiveMenuItem>
|
||||
</div>
|
||||
<ResponsiveMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => setLoginDialogOpen(true)}
|
||||
className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background"
|
||||
>
|
||||
<div className="flex gap-2 items-center justify-center w-full py-2">
|
||||
<Plus />
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setLogoutDialogOpen(true)}
|
||||
>
|
||||
@@ -110,13 +111,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
||||
<span className="shrink-0">{t('Logout')}</span>
|
||||
<SimpleUsername
|
||||
userId={pubkey}
|
||||
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate ml-auto"
|
||||
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
|
||||
/>
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { createRepostDraftEvent } from '@/lib/draft-event'
|
||||
import { getNoteBech32Id } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
@@ -21,13 +24,14 @@ import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
// external content
|
||||
if (!event) return { repostCount: 0, hasReposted: false }
|
||||
@@ -70,39 +74,95 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
disabled={!event}
|
||||
title={t('Repost')}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
const postEditor = (
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getNoteBech32Id(event)}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(false)
|
||||
repost()
|
||||
}}
|
||||
disabled={!canRepost}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<Repeat /> {t('Repost')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(false)
|
||||
checkLogin(() => {
|
||||
setIsPostDialogOpen(true)
|
||||
})
|
||||
}}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<PencilLine /> {t('Quote')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{postEditor}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveMenu open={open} onOpenChange={setOpen}>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
disabled={!event}
|
||||
title={t('Repost')}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!event) return
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||
</button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
repost()
|
||||
}}
|
||||
disabled={!canRepost}
|
||||
>
|
||||
<Repeat /> {t('Repost')}
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => {
|
||||
@@ -111,15 +171,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
}}
|
||||
>
|
||||
<PencilLine /> {t('Quote')}
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={event ? '\nnostr:' + getNoteBech32Id(event) : undefined}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{postEditor}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuLabel,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuTrigger
|
||||
} from '@/components/ui/responsive-menu'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Server } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -19,56 +22,84 @@ import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { event } = useStuff(stuff)
|
||||
const [relays, setRelays] = useState<string[]>([])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return
|
||||
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
setRelays(seenOn)
|
||||
}, [event])
|
||||
}, [])
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
|
||||
title={t('Seen on')}
|
||||
disabled={relays.length === 0}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Server />
|
||||
{relays.length > 0 && <div className="text-sm">{relays.length}</div>}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (relays.length === 0) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
|
||||
title={t('Seen on')}
|
||||
disabled
|
||||
>
|
||||
<Server />
|
||||
</button>
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
{relays.map((relay) => (
|
||||
<Button
|
||||
className="w-full p-6 justify-start text-lg gap-4"
|
||||
variant="ghost"
|
||||
key={relay}
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
setTimeout(() => {
|
||||
push(toRelay(relay))
|
||||
}, 50) // Timeout to allow the drawer to close before navigating
|
||||
}}
|
||||
>
|
||||
<RelayIcon url={relay} /> {simplifyUrl(relay)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
|
||||
title={t('Seen on')}
|
||||
>
|
||||
<Server />
|
||||
<div className="text-sm">{relays.length}</div>
|
||||
</button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuLabel>{t('Seen on')}</ResponsiveMenuLabel>
|
||||
<ResponsiveMenuSeparator />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{relays.map((relay) => (
|
||||
<ResponsiveMenuItem
|
||||
key={relay}
|
||||
onClick={() => {
|
||||
setTimeout(() => push(toRelay(relay)), 100) // slight delay to allow menu to close
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52">
|
||||
<RelayIcon url={relay} />
|
||||
{simplifyUrl(relay)}
|
||||
</ResponsiveMenuItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* ResponsiveMenu 组合式 API 使用示例
|
||||
*
|
||||
* 这个版本采用组合式 API,与 DropdownMenu/Drawer 的使用方式一致
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ResponsiveMenu,
|
||||
ResponsiveMenuTrigger,
|
||||
ResponsiveMenuContent,
|
||||
ResponsiveMenuItem,
|
||||
ResponsiveMenuSeparator,
|
||||
ResponsiveMenuLabel,
|
||||
ResponsiveMenuSub,
|
||||
ResponsiveMenuSubTrigger,
|
||||
ResponsiveMenuSubContent
|
||||
} from './responsive-menu'
|
||||
import { Button } from './button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './avatar'
|
||||
import { Badge } from './badge'
|
||||
import {
|
||||
Menu,
|
||||
Copy,
|
||||
Share2,
|
||||
Trash2,
|
||||
Settings,
|
||||
User,
|
||||
Bell,
|
||||
Wallet,
|
||||
Plus,
|
||||
LogOut,
|
||||
SatelliteDish
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// ============================================================================
|
||||
// 1. Basic Example
|
||||
// ============================================================================
|
||||
|
||||
export function BasicExample() {
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Menu />
|
||||
Actions
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied!')}>
|
||||
<Copy />
|
||||
Copy
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Shared!')}>
|
||||
<Share2 />
|
||||
Share
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuSeparator />
|
||||
|
||||
<ResponsiveMenuItem
|
||||
onClick={() => toast.error('Deleted!')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. With Sub Menu Example
|
||||
// ============================================================================
|
||||
|
||||
export function WithSubMenuExample() {
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu />
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Profile')}>
|
||||
<User />
|
||||
Profile
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuSub>
|
||||
<ResponsiveMenuSubTrigger>
|
||||
<Settings />
|
||||
Settings
|
||||
</ResponsiveMenuSubTrigger>
|
||||
<ResponsiveMenuSubContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('General settings')}>
|
||||
General
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuSeparator />
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Privacy settings')}>
|
||||
Privacy
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Advanced settings')}>
|
||||
Advanced
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuSubContent>
|
||||
</ResponsiveMenuSub>
|
||||
|
||||
<ResponsiveMenuSub>
|
||||
<ResponsiveMenuSubTrigger>
|
||||
<Bell />
|
||||
Notifications
|
||||
</ResponsiveMenuSubTrigger>
|
||||
<ResponsiveMenuSubContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Enabled all')}>
|
||||
Enable all
|
||||
</ResponsiveMenuItem>
|
||||
<ResponsiveMenuItem
|
||||
onClick={() => toast.success('Disabled all')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Disable all
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuSubContent>
|
||||
</ResponsiveMenuSub>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. Controlled Example
|
||||
// ============================================================================
|
||||
|
||||
export function ControlledExample() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ResponsiveMenu open={open} onOpenChange={setOpen}>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button>Open Menu</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied!')}>
|
||||
<Copy />
|
||||
Copy
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">Menu is {open ? 'open' : 'closed'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. Account Switcher Example
|
||||
// ============================================================================
|
||||
|
||||
export function AccountButtonExample() {
|
||||
const accounts = [
|
||||
{
|
||||
pubkey: 'npub1abc...',
|
||||
name: 'Alice',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
signerType: 'extension'
|
||||
},
|
||||
{
|
||||
pubkey: 'npub1def...',
|
||||
name: 'Bob',
|
||||
avatar: 'https://i.pravatar.cc/150?img=2',
|
||||
signerType: 'nsec'
|
||||
}
|
||||
]
|
||||
|
||||
const [currentAccount, setCurrentAccount] = React.useState(accounts[0])
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={currentAccount.avatar} />
|
||||
<AvatarFallback>{currentAccount.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 text-left truncate">{currentAccount.name}</span>
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent side="top" className="w-72">
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Wallet')}>
|
||||
<Wallet />
|
||||
Wallet
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuSeparator />
|
||||
<ResponsiveMenuLabel>Switch account</ResponsiveMenuLabel>
|
||||
|
||||
{accounts.map((account) => (
|
||||
<ResponsiveMenuItem
|
||||
key={account.pubkey}
|
||||
onClick={() => setCurrentAccount(account)}
|
||||
className={
|
||||
account.pubkey === currentAccount.pubkey ? 'cursor-default focus:bg-background' : ''
|
||||
}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={account.avatar} />
|
||||
<AvatarFallback>{account.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 w-0">
|
||||
<div className="font-medium truncate">{account.name}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{account.signerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`border border-muted-foreground rounded-full h-3.5 w-3.5 ${
|
||||
account.pubkey === currentAccount.pubkey && 'h-4 w-4 border-4 border-primary'
|
||||
}`}
|
||||
/>
|
||||
</ResponsiveMenuItem>
|
||||
))}
|
||||
|
||||
<div className="border border-dashed m-2 rounded-md">
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Add account')}>
|
||||
<div className="flex gap-2 items-center justify-center w-full py-2">
|
||||
<Plus />
|
||||
Add an Account
|
||||
</div>
|
||||
</ResponsiveMenuItem>
|
||||
</div>
|
||||
|
||||
<ResponsiveMenuItem
|
||||
onClick={() => toast.error('Logout')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut />
|
||||
<span className="shrink-0">Logout</span>
|
||||
<span className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate ml-auto">
|
||||
{currentAccount.name}
|
||||
</span>
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 5. Complex Sub Menu Example
|
||||
// ============================================================================
|
||||
|
||||
export function ComplexSubMenuExample() {
|
||||
const relays = [
|
||||
{ url: 'wss://relay1.example.com', name: 'Relay 1', status: 'connected' },
|
||||
{ url: 'wss://relay2.example.com', name: 'Relay 2', status: 'connecting' },
|
||||
{ url: 'wss://relay3.example.com', name: 'Relay 3', status: 'disconnected' }
|
||||
]
|
||||
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Menu />
|
||||
Actions
|
||||
</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent showScrollButtons>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied ID')}>
|
||||
<Copy />
|
||||
Copy ID
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied user ID')}>
|
||||
<Copy />
|
||||
Copy user ID
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied link')}>
|
||||
<Share2 />
|
||||
Copy share link
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuSeparator />
|
||||
|
||||
<ResponsiveMenuSub>
|
||||
<ResponsiveMenuSubTrigger>
|
||||
<SatelliteDish />
|
||||
Republish to ...
|
||||
</ResponsiveMenuSubTrigger>
|
||||
<ResponsiveMenuSubContent showScrollButtons>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Republishing to optimal relays')}>
|
||||
<div className="text-left">Optimal relays</div>
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuSeparator />
|
||||
|
||||
{relays.map((relay) => (
|
||||
<ResponsiveMenuItem
|
||||
key={relay.url}
|
||||
onClick={() => toast.success(`Republishing to ${relay.name}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<SatelliteDish className="h-4 w-4" />
|
||||
<div className="flex-1 truncate text-left">{relay.name}</div>
|
||||
<Badge
|
||||
variant={relay.status === 'connected' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{relay.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</ResponsiveMenuItem>
|
||||
))}
|
||||
</ResponsiveMenuSubContent>
|
||||
</ResponsiveMenuSub>
|
||||
|
||||
<ResponsiveMenuSeparator />
|
||||
|
||||
<ResponsiveMenuItem
|
||||
onClick={() => toast.error('Deleting...')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 6. Dynamic Content Example
|
||||
// ============================================================================
|
||||
|
||||
export function DynamicContentExample() {
|
||||
const [canDelete, setCanDelete] = React.useState(true)
|
||||
const [isPinned, setIsPinned] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCanDelete(!canDelete)}>
|
||||
Toggle Delete Permission
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsPinned(!isPinned)}>
|
||||
Toggle Pin Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button>Menu</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied')}>
|
||||
<Copy />
|
||||
Copy
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Shared')}>
|
||||
<Share2 />
|
||||
Share
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
{/* 条件渲染 */}
|
||||
{isPinned && (
|
||||
<>
|
||||
<ResponsiveMenuSeparator />
|
||||
<ResponsiveMenuItem onClick={() => setIsPinned(false)}>Unpin</ResponsiveMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<>
|
||||
<ResponsiveMenuSeparator />
|
||||
<ResponsiveMenuItem
|
||||
onClick={() => toast.error('Deleted')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</ResponsiveMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 7. Custom Style Example
|
||||
// ============================================================================
|
||||
|
||||
export function CustomStyleExample() {
|
||||
return (
|
||||
<ResponsiveMenu>
|
||||
<ResponsiveMenuTrigger asChild>
|
||||
<Button>Custom Menu</Button>
|
||||
</ResponsiveMenuTrigger>
|
||||
|
||||
<ResponsiveMenuContent
|
||||
className="w-80"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
showScrollButtons
|
||||
>
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Copied')}>
|
||||
<Copy />
|
||||
Copy
|
||||
</ResponsiveMenuItem>
|
||||
|
||||
<ResponsiveMenuItem onClick={() => toast.success('Shared')}>
|
||||
<Share2 />
|
||||
Share
|
||||
</ResponsiveMenuItem>
|
||||
</ResponsiveMenuContent>
|
||||
</ResponsiveMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ArrowLeft, Check, ChevronRight } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
// ============================================================================
|
||||
// Context
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuContextValue {
|
||||
isSmallScreen: boolean
|
||||
closeMenu: () => void
|
||||
openSubMenu: (title: React.ReactNode, content: React.ReactNode) => void
|
||||
goBack: () => void
|
||||
}
|
||||
|
||||
const ResponsiveMenuContext = React.createContext<ResponsiveMenuContextValue | undefined>(undefined)
|
||||
|
||||
function useResponsiveMenuContext() {
|
||||
const context = React.useContext(ResponsiveMenuContext)
|
||||
if (!context) {
|
||||
throw new Error('ResponsiveMenu components must be used within ResponsiveMenu')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Root Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ResponsiveMenu({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange
|
||||
}: ResponsiveMenuProps) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
|
||||
const [subMenuContent, setSubMenuContent] = React.useState<React.ReactNode>(null)
|
||||
const [subMenuTitle, setSubMenuTitle] = React.useState<React.ReactNode>('')
|
||||
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setUncontrolledOpen(open)
|
||||
}
|
||||
onOpenChange?.(open)
|
||||
|
||||
// Reset submenu when closing
|
||||
if (!open) {
|
||||
setSubMenuContent(null)
|
||||
setSubMenuTitle('')
|
||||
}
|
||||
},
|
||||
[controlledOpen, onOpenChange]
|
||||
)
|
||||
|
||||
const closeMenu = React.useCallback(() => {
|
||||
handleOpenChange(false)
|
||||
}, [handleOpenChange])
|
||||
|
||||
const openSubMenu = React.useCallback((title: React.ReactNode, content: React.ReactNode) => {
|
||||
setSubMenuTitle(title)
|
||||
setSubMenuContent(content)
|
||||
}, [])
|
||||
|
||||
const goBack = React.useCallback(() => {
|
||||
setSubMenuContent(null)
|
||||
setSubMenuTitle('')
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
isSmallScreen,
|
||||
closeMenu,
|
||||
openSubMenu,
|
||||
goBack
|
||||
}),
|
||||
[isSmallScreen, closeMenu, openSubMenu, goBack]
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<ResponsiveMenuContext.Provider value={contextValue}>
|
||||
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === ResponsiveMenuTrigger) {
|
||||
const props = child.props as { children: React.ReactNode }
|
||||
return <DrawerTrigger asChild>{props.children}</DrawerTrigger>
|
||||
}
|
||||
if (React.isValidElement(child) && child.type === ResponsiveMenuContent) {
|
||||
const props = child.props as { children: React.ReactNode; className?: string }
|
||||
return (
|
||||
<>
|
||||
<DrawerOverlay onClick={closeMenu} />
|
||||
<DrawerContent hideOverlay className={cn('max-h-[80vh]', props.className)}>
|
||||
<div
|
||||
className="overflow-y-auto overscroll-contain py-2"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
{subMenuContent ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={goBack}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5 mb-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft />
|
||||
{subMenuTitle}
|
||||
</Button>
|
||||
<div className="border-t border-border mb-2" />
|
||||
{subMenuContent}
|
||||
</>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Drawer>
|
||||
</ResponsiveMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveMenuContext.Provider value={contextValue}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DropdownMenu>
|
||||
</ResponsiveMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trigger Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuTriggerProps {
|
||||
children: React.ReactNode
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function ResponsiveMenuTrigger({ children, asChild }: ResponsiveMenuTriggerProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
// Trigger is handled in ResponsiveMenu root
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <DropdownMenuTrigger asChild={asChild}>{children}</DropdownMenuTrigger>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuContentProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
sideOffset?: number
|
||||
showScrollButtons?: boolean
|
||||
}
|
||||
|
||||
export function ResponsiveMenuContent({
|
||||
children,
|
||||
className,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
showScrollButtons = true
|
||||
}: ResponsiveMenuContentProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
// Content is handled in ResponsiveMenu root
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
className={cn('max-h-[50vh]', className)}
|
||||
align={align}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
showScrollButtons={showScrollButtons}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuItemProps {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ResponsiveMenuItem({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
disabled
|
||||
}: ResponsiveMenuItemProps) {
|
||||
const { isSmallScreen, closeMenu } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
closeMenu()
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
closeMenu()
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CheckboxItem Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuCheckboxItemProps {
|
||||
children: React.ReactNode
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ResponsiveMenuCheckboxItem({
|
||||
children,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
disabled
|
||||
}: ResponsiveMenuCheckboxItemProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
onCheckedChange(!checked)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-3 clickable',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center size-4 shrink-0">
|
||||
{checked && <Check className="size-4" />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={checked}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Separator Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuSeparatorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ResponsiveMenuSeparator({ className }: ResponsiveMenuSeparatorProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return <Separator className={className} />
|
||||
}
|
||||
|
||||
return <DropdownMenuSeparator className={className} />
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Label Component
|
||||
// ============================================================================
|
||||
|
||||
interface ResponsiveMenuLabelProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ResponsiveMenuLabel({ children, className }: ResponsiveMenuLabelProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div className={cn('px-6 py-3 text-sm font-semibold text-muted-foreground', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DropdownMenuLabel className={className}>{children}</DropdownMenuLabel>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sub Menu Components
|
||||
// ============================================================================
|
||||
|
||||
const ResponsiveMenuSubContext = React.createContext<{
|
||||
registerSubContent: (content: React.ReactNode) => void
|
||||
} | null>(null)
|
||||
|
||||
interface ResponsiveMenuSubProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ResponsiveMenuSub({ children }: ResponsiveMenuSubProps) {
|
||||
const { isSmallScreen, openSubMenu } = useResponsiveMenuContext()
|
||||
const [subContent, setSubContent] = React.useState<React.ReactNode>(null)
|
||||
const [title, setTitle] = React.useState<React.ReactNode>('')
|
||||
|
||||
const registerSubContent = React.useCallback((content: React.ReactNode) => {
|
||||
setSubContent(content)
|
||||
}, [])
|
||||
|
||||
const registerTitle = React.useCallback((titleContent: React.ReactNode) => {
|
||||
setTitle(titleContent)
|
||||
}, [])
|
||||
|
||||
const handleTriggerClick = React.useCallback(() => {
|
||||
if (isSmallScreen && subContent) {
|
||||
openSubMenu(title, subContent)
|
||||
}
|
||||
}, [isSmallScreen, subContent, openSubMenu, title])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<ResponsiveMenuSubContext.Provider value={{ registerSubContent }}>
|
||||
<ResponsiveMenuSubTitleContext.Provider
|
||||
value={{ registerTitle, onTriggerClick: handleTriggerClick }}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveMenuSubTitleContext.Provider>
|
||||
</ResponsiveMenuSubContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return <DropdownMenuSub>{children}</DropdownMenuSub>
|
||||
}
|
||||
|
||||
const ResponsiveMenuSubTitleContext = React.createContext<{
|
||||
registerTitle: (title: React.ReactNode) => void
|
||||
onTriggerClick: () => void
|
||||
} | null>(null)
|
||||
|
||||
interface ResponsiveMenuSubTriggerProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ResponsiveMenuSubTrigger({ children, className }: ResponsiveMenuSubTriggerProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
const subTitleContext = React.useContext(ResponsiveMenuSubTitleContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSmallScreen && subTitleContext) {
|
||||
subTitleContext.registerTitle(children)
|
||||
}
|
||||
}, [isSmallScreen, children, subTitleContext])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Button
|
||||
onClick={subTitleContext?.onTriggerClick}
|
||||
className={cn('w-full p-6 justify-between text-lg gap-4 [&_svg]:size-5', className)}
|
||||
variant="ghost"
|
||||
>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return <DropdownMenuSubTrigger className={className}>{children}</DropdownMenuSubTrigger>
|
||||
}
|
||||
|
||||
interface ResponsiveMenuSubContentProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
showScrollButtons?: boolean
|
||||
}
|
||||
|
||||
export function ResponsiveMenuSubContent({
|
||||
children,
|
||||
className,
|
||||
showScrollButtons = true
|
||||
}: ResponsiveMenuSubContentProps) {
|
||||
const { isSmallScreen } = useResponsiveMenuContext()
|
||||
const subContext = React.useContext(ResponsiveMenuSubContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSmallScreen && subContext) {
|
||||
subContext.registerSubContent(children)
|
||||
}
|
||||
}, [isSmallScreen, children, subContext])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return null // Content will be shown via context
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSubContent
|
||||
className={cn('max-h-[50vh]', className)}
|
||||
showScrollButtons={showScrollButtons}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuSubContent>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user