From 1dc18645b27dcc02320a5bf873292f299e2e9701 Mon Sep 17 00:00:00 2001 From: codytseng Date: Wed, 26 Nov 2025 22:18:17 +0800 Subject: [PATCH] refactor: responsive menu --- src/components/ExternalLink/index.tsx | 129 ++--- .../FavoriteRelaysSetting/RelaySet.tsx | 75 +-- src/components/MuteButton/index.tsx | 78 +-- src/components/NoteOptions/DesktopMenu.tsx | 64 --- src/components/NoteOptions/MobileMenu.tsx | 79 --- src/components/NoteOptions/index.tsx | 108 ++-- src/components/NoteOptions/useMenuActions.tsx | 37 +- src/components/PostEditor/Mentions.tsx | 165 ++---- .../PostEditor/PostRelaySelector.tsx | 188 ++----- src/components/ProfileOptions/index.tsx | 128 +---- .../SaveRelayDropdownMenu/index.tsx | 154 ++---- src/components/Sidebar/AccountButton.tsx | 61 ++- src/components/StuffStats/RepostButton.tsx | 135 ++--- src/components/StuffStats/SeenOnButton.tsx | 107 ++-- src/components/ui/responsive-menu.example.tsx | 433 ++++++++++++++++ src/components/ui/responsive-menu.tsx | 480 ++++++++++++++++++ 16 files changed, 1324 insertions(+), 1097 deletions(-) delete mode 100644 src/components/NoteOptions/DesktopMenu.tsx delete mode 100644 src/components/NoteOptions/MobileMenu.tsx create mode 100644 src/components/ui/responsive-menu.example.tsx create mode 100644 src/components/ui/responsive-menu.tsx diff --git a/src/components/ExternalLink/index.tsx b/src/components/ExternalLink/index.tsx index e622355..d2c97ed 100644 --- a/src/components/ExternalLink/index.tsx +++ b/src/components/ExternalLink/index.tsx @@ -1,18 +1,15 @@ import { useSecondaryPage } from '@/PageManager' -import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-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, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' export default function ExternalLink({ @@ -25,29 +22,9 @@ 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 ( { - e.stopPropagation() - if (isSmallScreen) { - setIsDrawerOpen(true) - } - }} - title={url} - > - {displayUrl} - - ) + const handleOpenLink = (e?: React.MouseEvent) => { + e?.stopPropagation() + window.open(url, '_blank', 'noreferrer') + } - if (isSmallScreen) { - return ( - <> - {trigger} - - { - e.stopPropagation() - setIsDrawerOpen(false) - }} - /> - -
- - -
-
-
- - ) + const handleViewDiscussions = (e?: React.MouseEvent) => { + e?.stopPropagation() + setTimeout(() => push(toExternalContent(url)), 100) // wait for menu to close } return ( - - - - {displayUrl} - - - e.stopPropagation()}> - - - {t('Open link')} - - - - {t('View Nostr discussions')} - - - +
e.stopPropagation()}> + + + + {displayUrl} + + + + + + {t('Open link')} + + + + {t('View Nostr discussions')} + + + +
) } diff --git a/src/components/FavoriteRelaysSetting/RelaySet.tsx b/src/components/FavoriteRelaysSetting/RelaySet.tsx index d52387d..4ce2155 100644 --- a/src/components/FavoriteRelaysSetting/RelaySet.tsx +++ b/src/components/FavoriteRelaysSetting/RelaySet.tsx @@ -1,14 +1,12 @@ import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' 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' @@ -24,7 +22,6 @@ 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' @@ -139,16 +136,9 @@ function RelayUrlsExpandToggle({ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) { const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() const { deleteRelaySet } = useFavoriteRelays() const { setRenamingRelaySetId } = useRelaySetsSettingComponent() - const trigger = ( - - ) - const rename = () => { setRenamingRelaySetId(relaySet.id) } @@ -159,53 +149,30 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) { ) } - if (isSmallScreen) { - return ( - - {trigger} - -
- - - {t('Rename')} - - - - {t('Copy share link')} - - deleteRelaySet(relaySet.id)} - > - - {t('Delete')} - -
-
-
- ) - } - return ( - - {trigger} - - + + + + + + {t('Rename')} - - + + {t('Copy share link')} - - + deleteRelaySet(relaySet.id)} > {t('Delete')} - - - + + + ) } diff --git a/src/components/MuteButton/index.tsx b/src/components/MuteButton/index.tsx index 651c3a9..5359cc7 100644 --- a/src/components/MuteButton/index.tsx +++ b/src/components/MuteButton/index.tsx @@ -1,14 +1,12 @@ import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-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' @@ -16,7 +14,6 @@ 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() @@ -74,63 +71,34 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { ) } - const trigger = ( - - ) - - if (isSmallScreen) { - return ( - - {trigger} - -
- - -
-
-
- ) - } - return ( - - {trigger} - - + + + + + + handleMute(e, true)} className="text-destructive focus:text-destructive" > {t('Mute user privately')} - - + handleMute(e, false)} className="text-destructive focus:text-destructive" > {t('Mute user publicly')} - - - + + + ) } diff --git a/src/components/NoteOptions/DesktopMenu.tsx b/src/components/NoteOptions/DesktopMenu.tsx deleted file mode 100644 index c010718..0000000 --- a/src/components/NoteOptions/DesktopMenu.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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 ( - - {trigger} - - {menuActions.map((action, index) => { - const Icon = action.icon - return ( -
- {action.separator && index > 0 && } - {action.subMenu ? ( - - - - {action.label} - - - {action.subMenu.map((subAction, subIndex) => ( -
- {subAction.separator && subIndex > 0 && } - - {subAction.label} - -
- ))} -
-
- ) : ( - - - {action.label} - - )} -
- ) - })} -
-
- ) -} diff --git a/src/components/NoteOptions/MobileMenu.tsx b/src/components/NoteOptions/MobileMenu.tsx deleted file mode 100644 index a2e66ce..0000000 --- a/src/components/NoteOptions/MobileMenu.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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} - - - -
- {!showSubMenu ? ( - menuActions.map((action, index) => { - const Icon = action.icon - return ( - - ) - }) - ) : ( - <> - -
- {activeSubMenu.map((subAction, index) => ( - - ))} - - )} -
- - - - ) -} diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index eb4b7a6..8648d51 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -1,72 +1,76 @@ -import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuSeparator, + ResponsiveMenuSub, + ResponsiveMenuSubContent, + ResponsiveMenuSubTrigger, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' 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 { SubMenuAction, useMenuActions } from './useMenuActions' +import { 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([]) - 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, - isSmallScreen + setIsReportDialogOpen }) - const trigger = ( - - ) - return (
e.stopPropagation()}> - {isSmallScreen ? ( - - ) : ( - - )} + + + + + + + {menuActions.map((action, index) => { + const Icon = action.icon + return ( +
+ {action.separator && index > 0 && } + {action.subMenu ? ( + + + + {action.label} + + + {action.subMenu.map((subAction, subIndex) => ( +
+ {subAction.separator && subIndex > 0 && } + + {subAction.label} + +
+ ))} +
+
+ ) : ( + + + {action.label} + + )} +
+ ) + })} +
+
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, - isSmallScreen + setIsReportDialogOpen }: UseMenuActionsProps) { const { t } = useTranslation() const { pubkey, attemptDelete } = useNostr() @@ -76,7 +70,6 @@ export function useMenuActions({ items.push({ label:
{t('Optimal relays')}
, onClick: async () => { - closeDrawer() const promise = async () => { const relays = await client.determineTargetRelays(event) if (relays?.length) { @@ -107,7 +100,6 @@ export function useMenuActions({ .map((set, index) => ({ label:
{set.name}
, onClick: async () => { - closeDrawer() const promise = client.publishEvent(set.relayUrls, event) toast.promise(promise, { loading: t('Republishing...'), @@ -137,7 +129,6 @@ export function useMenuActions({
), onClick: async () => { - closeDrawer() const promise = client.publishEvent([relay], event) toast.promise(promise, { loading: t('Republishing...'), @@ -158,7 +149,7 @@ export function useMenuActions({ } return items - }, [pubkey, relayUrls, relaySets]) + }, [pubkey, relayUrls, relaySets, event, t]) const menuActions: MenuAction[] = useMemo(() => { const actions: MenuAction[] = [ @@ -167,7 +158,6 @@ export function useMenuActions({ label: t('Copy event ID'), onClick: () => { navigator.clipboard.writeText(getNoteBech32Id(event)) - closeDrawer() } }, { @@ -175,7 +165,6 @@ export function useMenuActions({ label: t('Copy user ID'), onClick: () => { navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') - closeDrawer() } }, { @@ -183,14 +172,12 @@ 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 @@ -202,10 +189,7 @@ export function useMenuActions({ actions.push({ icon: SatelliteDish, label: t('Republish to ...'), - onClick: isSmallScreen - ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) - : undefined, - subMenu: isSmallScreen ? undefined : broadcastSubMenu, + subMenu: broadcastSubMenu, separator: true }) } @@ -216,7 +200,6 @@ 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)) } }) @@ -228,7 +211,6 @@ export function useMenuActions({ label: t('Report'), className: 'text-destructive focus:text-destructive', onClick: () => { - closeDrawer() setIsReportDialogOpen(true) }, separator: true @@ -241,7 +223,6 @@ export function useMenuActions({ icon: Bell, label: t('Unmute user'), onClick: () => { - closeDrawer() unmutePubkey(event.pubkey) }, className: 'text-destructive focus:text-destructive', @@ -253,7 +234,6 @@ export function useMenuActions({ icon: BellOff, label: t('Mute user privately'), onClick: () => { - closeDrawer() mutePubkeyPrivately(event.pubkey) }, className: 'text-destructive focus:text-destructive', @@ -263,7 +243,6 @@ export function useMenuActions({ icon: BellOff, label: t('Mute user publicly'), onClick: () => { - closeDrawer() mutePubkeyPublicly(event.pubkey) }, className: 'text-destructive focus:text-destructive' @@ -277,7 +256,6 @@ export function useMenuActions({ icon: Trash2, label: t('Try deleting this note'), onClick: () => { - closeDrawer() attemptDelete(event) }, className: 'text-destructive focus:text-destructive', @@ -291,15 +269,16 @@ export function useMenuActions({ event, pubkey, isMuted, - isSmallScreen, broadcastSubMenu, pinnedEventHexIdSet, - closeDrawer, - showSubMenuActions, setIsRawEventDialogOpen, + setIsReportDialogOpen, mutePubkeyPrivately, mutePubkeyPublicly, - unmutePubkey + unmutePubkey, + unpin, + pin, + attemptDelete ]) return menuActions diff --git a/src/components/PostEditor/Mentions.tsx b/src/components/PostEditor/Mentions.tsx index 27f1d58..c90f5f8 100644 --- a/src/components/PostEditor/Mentions.tsx +++ b/src/components/PostEditor/Mentions.tsx @@ -1,19 +1,15 @@ import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' + ResponsiveMenu, + ResponsiveMenuCheckboxItem, + ResponsiveMenuContent, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' 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, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUsername } from '../Username' @@ -30,8 +26,6 @@ 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([]) @@ -64,69 +58,11 @@ export default function Mentions({ useEffect(() => { const newMentions = potentialMentions.filter((pubkey) => !removedPubkeys.includes(pubkey)) setMentions(newMentions) - }, [potentialMentions, removedPubkeys]) - - const items = useMemo(() => { - return potentialMentions.map((_, index) => { - const pubkey = potentialMentions[potentialMentions.length - 1 - index] - const isParentPubkey = pubkey === parentEventPubkey - return ( - { - if (isParentPubkey) { - return - } - if (checked) { - setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey)) - } else { - setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey]) - } - }} - disabled={isParentPubkey} - > - - - - ) - }) - }, [potentialMentions, parentEventPubkey, mentions]) - - if (isSmallScreen) { - return ( - <> - - - setIsDrawerOpen(false)} /> - -
- {items} -
-
-
- - ) - } + }, [potentialMentions, removedPubkeys, setMentions]) return ( - - + + - - - {items} - - - ) -} - -function MenuItem({ - children, - checked, - disabled, - onCheckedChange -}: { - children: React.ReactNode - checked: boolean - disabled?: boolean - onCheckedChange: (checked: boolean) => void -}) { - const { isSmallScreen } = useScreenSize() - - if (isSmallScreen) { - return ( -
{ - if (disabled) return - onCheckedChange(!checked) - }} - className={cn( - 'flex items-center gap-2 px-4 py-3 clickable', - disabled ? 'opacity-50 pointer-events-none' : '' - )} - > -
- {checked && } -
- {children} -
- ) - } - - return ( - e.preventDefault()} - onCheckedChange={onCheckedChange} - className="flex items-center gap-2" - > - {children} - + + + {potentialMentions.map((_, index) => { + const pubkey = potentialMentions[potentialMentions.length - 1 - index] + const isParentPubkey = pubkey === parentEventPubkey + return ( + { + if (isParentPubkey) { + return + } + if (checked) { + setRemovedPubkeys((pubkeys) => pubkeys.filter((p) => p !== pubkey)) + } else { + setRemovedPubkeys((pubkeys) => [...pubkeys, pubkey]) + } + }} + disabled={isParentPubkey} + > + + + + ) + })} + + ) } diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 820abde..4b8c3cc 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,21 +1,17 @@ import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Label } from '@/components/ui/label' -import { Separator } from '@/components/ui/separator' +import { + ResponsiveMenu, + ResponsiveMenuCheckboxItem, + ResponsiveMenuContent, + ResponsiveMenuSeparator, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' 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' @@ -47,8 +43,6 @@ export default function PostRelaySelector({ setAdditionalRelayUrls: Dispatch> }) { const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() - const [isDrawerOpen, setIsDrawerOpen] = useState(false) const { relayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() const [postTargetItems, setPostTargetItems] = useState([]) @@ -79,8 +73,10 @@ export default function PostRelaySelector({ : simplifyUrl(item.urls[0]) } } - const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays') - const relayCount = postTargetItems.reduce((count, item) => { + const hasWriteRelays = postTargetItems.some( + (item: TPostTargetItem) => item.type === 'writeRelays' + ) + const relayCount = postTargetItems.reduce((count: number, item: TPostTargetItem) => { if (item.type === 'relay') { return count + 1 } @@ -93,7 +89,7 @@ export default function PostRelaySelector({ return t('Optimal relays and {{count}} other relays', { count: relayCount }) } return t('{{count}} relays', { count: relayCount }) - }, [postTargetItems]) + }, [postTargetItems, t]) useEffect(() => { if (openFrom && openFrom.length) { @@ -108,8 +104,10 @@ export default function PostRelaySelector({ }, [openFrom, parentEventSeenOnRelays]) useEffect(() => { - const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays') - const relayUrls = postTargetItems.flatMap((item) => { + const isProtected = postTargetItems.every( + (item: TPostTargetItem) => item.type !== 'writeRelays' + ) + const relayUrls = postTargetItems.flatMap((item: TPostTargetItem) => { if (item.type === 'relay') { return [item.url] } @@ -119,24 +117,26 @@ export default function PostRelaySelector({ return [] }) - setIsProtectedEvent(isProtectedEvent) + setIsProtectedEvent(isProtected) setAdditionalRelayUrls(relayUrls) - }, [postTargetItems]) + }, [postTargetItems, setIsProtectedEvent, setAdditionalRelayUrls]) const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => { if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }]) + setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'writeRelays' }]) } else { - setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays')) + setPostTargetItems((prev: TPostTargetItem[]) => + prev.filter((item: TPostTargetItem) => item.type !== 'writeRelays') + ) } }, []) const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => { if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'relay', url }]) + setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relay', url }]) } else { - setPostTargetItems((prev) => - prev.filter((item) => !(item.type === 'relay' && item.url === url)) + setPostTargetItems((prev: TPostTargetItem[]) => + prev.filter((item: TPostTargetItem) => !(item.type === 'relay' && item.url === url)) ) } }, []) @@ -144,152 +144,74 @@ export default function PostRelaySelector({ const handleRelaySetCheckedChange = useCallback( (checked: boolean, id: string, urls: string[]) => { if (checked) { - setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }]) + setPostTargetItems((prev: TPostTargetItem[]) => [...prev, { type: 'relaySet', id, urls }]) } else { - setPostTargetItems((prev) => - prev.filter((item) => !(item.type === 'relaySet' && item.id === id)) + setPostTargetItems((prev: TPostTargetItem[]) => + prev.filter((item: TPostTargetItem) => !(item.type === 'relaySet' && item.id === id)) ) } }, [] ) - const content = useMemo(() => { - return ( - <> - item.type === 'writeRelays')} + return ( + + +
+ + +
+
+ + + item.type === 'writeRelays')} onCheckedChange={handleWriteRelaysCheckedChange} > {t('Write relays')} -
+ {relaySets.length > 0 && ( <> - + {relaySets .filter(({ relayUrls }) => relayUrls.length) .map(({ id, name, relayUrls }) => ( - item.type === 'relaySet' && item.id === id + (item: TPostTargetItem) => item.type === 'relaySet' && item.id === id )} onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)} >
{name} ({relayUrls.length})
-
+ ))} )} {selectableRelays.length > 0 && ( <> - + {selectableRelays.map((url) => ( - item.type === 'relay' && item.url === url)} + checked={postTargetItems.some( + (item: TPostTargetItem) => item.type === 'relay' && item.url === url + )} onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)} >
{simplifyUrl(url)}
-
+ ))} )} - - ) - }, [postTargetItems, relaySets, selectableRelays]) - - if (isSmallScreen) { - return ( - <> -
- - -
- - setIsDrawerOpen(false)} /> - -
- {content} -
-
-
- - ) - } - - return ( - -
- - - - -
- - {content} - -
- ) -} - -function MenuSeparator() { - const { isSmallScreen } = useScreenSize() - if (isSmallScreen) { - return - } - return -} - -function MenuItem({ - children, - checked, - onCheckedChange -}: { - children: React.ReactNode - checked: boolean - onCheckedChange: (checked: boolean) => void -}) { - const { isSmallScreen } = useScreenSize() - - if (isSmallScreen) { - return ( -
onCheckedChange(!checked)} - className="flex items-center gap-2 px-4 py-3 clickable" - > -
- {checked && } -
- {children} -
- ) - } - - return ( - e.preventDefault()} - onCheckedChange={onCheckedChange} - className="flex items-center gap-2" - > - {children} - + + ) } diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 8b825d3..7bca365 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -1,147 +1,67 @@ import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-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, useState } from 'react' +import { useMemo } 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 - const trigger = ( - - ) - - if (isSmallScreen) { - return ( - <> - {trigger} - - setIsDrawerOpen(false)} /> - -
- - {accountPubkey ? ( - isMuted ? ( - - ) : ( - <> - - - - ) - ) : null} -
-
-
- - ) - } - return ( - - {trigger} - - navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}> + + + + + + + navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}> {t('Copy user ID')} - + {accountPubkey ? ( isMuted ? ( - unmutePubkey(pubkey)} className="text-destructive focus:text-destructive" > {t('Unmute user')} - + ) : ( <> - mutePubkeyPrivately(pubkey)} className="text-destructive focus:text-destructive" > {t('Mute user privately')} - - + mutePubkeyPublicly(pubkey)} className="text-destructive focus:text-destructive" > {t('Mute user publicly')} - + ) ) : null} - - + + ) } diff --git a/src/components/SaveRelayDropdownMenu/index.tsx b/src/components/SaveRelayDropdownMenu/index.tsx index 304c303..907501d 100644 --- a/src/components/SaveRelayDropdownMenu/index.tsx +++ b/src/components/SaveRelayDropdownMenu/index.tsx @@ -1,29 +1,19 @@ import { Button } from '@/components/ui/button' import { - 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' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuLabel, + ResponsiveMenuSeparator, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' 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, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import DrawerMenuItem from '../DrawerMenuItem' export default function SaveRelayDropdownMenu({ urls, @@ -33,7 +23,6 @@ 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(() => { @@ -41,73 +30,39 @@ export default function SaveRelayDropdownMenu({ normalizedUrls.every((url) => favoriteRelays.includes(url)) || relaySets.some((set) => normalizedUrls.every((url) => set.relayUrls.includes(url))) ) - }, [relaySets, normalizedUrls]) - const [isDrawerOpen, setIsDrawerOpen] = useState(false) - - const trigger = bigButton ? ( - - ) : ( - - ) - - if (isSmallScreen) { - return ( -
- {trigger} -
e.stopPropagation()}> - - setIsDrawerOpen(false)} /> - - - {t('Save to')} ... - -
- - {relaySets.map((set) => ( - - ))} - - -
-
-
-
-
- ) - } + }, [relaySets, normalizedUrls, favoriteRelays]) return ( - - - {trigger} - - e.stopPropagation()}> - {t('Save to')} ... - - - {relaySets.map((set) => ( - - ))} - - - - +
e.stopPropagation()}> + + + {bigButton ? ( + + ) : ( + + )} + + + {t('Save to')} ... + + + {relaySets.map((set) => ( + + ))} + + + + +
) } 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)), @@ -122,25 +77,15 @@ function RelayItem({ urls }: { urls: string[] }) { } } - if (isSmallScreen) { - return ( - - {saved ? : } - {saved ? t('Unfavorite') : t('Favorite')} - - ) - } - return ( - + {saved ? : } {saved ? t('Unfavorite') : t('Favorite')} - + ) } 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)) @@ -163,26 +108,16 @@ function RelaySetItem({ set, urls }: { set: TRelaySet; urls: string[] }) { } } - if (isSmallScreen) { - return ( - - {saved ? : } - {set.name} - - ) - } - return ( - + {saved ? : } {set.name} - + ) } function SaveToNewSet({ urls }: { urls: string[] }) { const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() const { pubkey, startLogin } = useNostr() const { createRelaySet } = useFavoriteRelays() @@ -197,19 +132,10 @@ function SaveToNewSet({ urls }: { urls: string[] }) { } } - if (isSmallScreen) { - return ( - - - {t('Save to a new relay set')} - - ) - } - return ( - + {t('Save to a new relay set')} - + ) } diff --git a/src/components/Sidebar/AccountButton.tsx b/src/components/Sidebar/AccountButton.tsx index 26e905f..77d8a2a 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -1,12 +1,12 @@ import { Button } from '@/components/ui/button' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuLabel, + ResponsiveMenuSeparator, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-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 ( - - + + - - - push(toWallet())}> + + + push(toWallet())}> {t('Wallet')} - - - {t('Switch account')} + + + {t('Switch account')} {accounts.map((act) => ( - { @@ -92,18 +92,17 @@ function ProfileButton({ collapse }: { collapse: boolean }) { act.pubkey === pubkey && 'size-4 border-4 border-primary' )} /> - + ))} - setLoginDialogOpen(true)} - className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background" - > -
- - {t('Add an Account')} -
-
- + setLoginDialogOpen(true)}> +
+ + {t('Add an Account')} +
+
+
+ setLogoutDialogOpen(true)} > @@ -111,13 +110,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) { {t('Logout')} - - + + - + ) } diff --git a/src/components/StuffStats/RepostButton.tsx b/src/components/StuffStats/RepostButton.tsx index 0ca3be4..f976101 100644 --- a/src/components/StuffStats/RepostButton.tsx +++ b/src/components/StuffStats/RepostButton.tsx @@ -1,18 +1,15 @@ -import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { useStuffStatsById } from '@/hooks/useStuffStatsById' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-menu' import { useStuff } from '@/hooks/useStuff' +import { useStuffStatsById } from '@/hooks/useStuffStatsById' 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' @@ -24,14 +21,13 @@ 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 [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [open, setOpen] = useState(false) const { repostCount, hasReposted } = useMemo(() => { // external content if (!event) return { repostCount: 0, hasReposted: false } @@ -74,95 +70,39 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) { }) } - const trigger = ( - - ) - - if (!event) { - return trigger - } - - const postEditor = ( - - ) - - if (isSmallScreen) { - return ( - <> - {trigger} - - setIsDrawerOpen(false)} /> - -
- - -
-
-
- {postEditor} - - ) - } - return ( <> - - {trigger} - - + + + + + + { + e?.stopPropagation() repost() }} disabled={!canRepost} > {t('Repost')} - - + { e.stopPropagation() checkLogin(() => { @@ -171,10 +111,15 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) { }} > {t('Quote')} - - - - {postEditor} + + + + + ) } diff --git a/src/components/StuffStats/SeenOnButton.tsx b/src/components/StuffStats/SeenOnButton.tsx index 84d3b49..ea6f196 100644 --- a/src/components/StuffStats/SeenOnButton.tsx +++ b/src/components/StuffStats/SeenOnButton.tsx @@ -1,18 +1,15 @@ import { useSecondaryPage } from '@/PageManager' -import { Button } from '@/components/ui/button' -import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' + ResponsiveMenu, + ResponsiveMenuContent, + ResponsiveMenuItem, + ResponsiveMenuLabel, + ResponsiveMenuSeparator, + ResponsiveMenuTrigger +} from '@/components/ui/responsive-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' @@ -22,84 +19,56 @@ 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([]) - const [isDrawerOpen, setIsDrawerOpen] = useState(false) useEffect(() => { if (!event) return const seenOn = client.getSeenEventRelayUrls(event.id) setRelays(seenOn) - }, []) - - const trigger = ( - - ) + }, [event]) if (relays.length === 0) { - return trigger - } - - if (isSmallScreen) { return ( - <> - {trigger} - - setIsDrawerOpen(false)} /> - -
- {relays.map((relay) => ( - - ))} -
-
-
- + ) } return ( - - {trigger} - - {t('Seen on')} - + + + + + + + {t('Seen on')} + {relays.map((relay) => ( - push(toRelay(relay))} className="min-w-52"> + { + setTimeout(() => push(toRelay(relay)), 100) // slight delay to allow menu to close + }} + > {simplifyUrl(relay)} - + ))} - - + + ) } diff --git a/src/components/ui/responsive-menu.example.tsx b/src/components/ui/responsive-menu.example.tsx new file mode 100644 index 0000000..0902fbe --- /dev/null +++ b/src/components/ui/responsive-menu.example.tsx @@ -0,0 +1,433 @@ +/** + * 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 ( + + + + + + + toast.success('Copied!')}> + + Copy + + + toast.success('Shared!')}> + + Share + + + + + toast.error('Deleted!')} + className="text-destructive focus:text-destructive" + > + + Delete + + + + ) +} + +// ============================================================================ +// 2. With Sub Menu Example +// ============================================================================ + +export function WithSubMenuExample() { + return ( + + + + + + + toast.success('Profile')}> + + Profile + + + + + + Settings + + + toast.success('General settings')}> + General + + + toast.success('Privacy settings')}> + Privacy + + toast.success('Advanced settings')}> + Advanced + + + + + + + + Notifications + + + toast.success('Enabled all')}> + Enable all + + toast.success('Disabled all')} + className="text-destructive focus:text-destructive" + > + Disable all + + + + + + ) +} + +// ============================================================================ +// 3. Controlled Example +// ============================================================================ + +export function ControlledExample() { + const [open, setOpen] = React.useState(false) + + return ( +
+ + + + + + + toast.success('Copied!')}> + + Copy + + + + +

Menu is {open ? 'open' : 'closed'}

+
+ ) +} + +// ============================================================================ +// 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 ( + + + + + + + toast.success('Wallet')}> + + Wallet + + + + Switch account + + {accounts.map((account) => ( + setCurrentAccount(account)} + className={ + account.pubkey === currentAccount.pubkey ? 'cursor-default focus:bg-background' : '' + } + > +
+ + + {account.name[0]} + +
+
{account.name}
+ + {account.signerType} + +
+
+
+ + ))} + +
+ toast.success('Add account')}> +
+ + Add an Account +
+
+
+ + toast.error('Logout')} + className="text-destructive focus:text-destructive" + > + + Logout + + {currentAccount.name} + + + + + ) +} + +// ============================================================================ +// 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 ( + + + + + + + toast.success('Copied ID')}> + + Copy ID + + + toast.success('Copied user ID')}> + + Copy user ID + + + toast.success('Copied link')}> + + Copy share link + + + + + + + + Republish to ... + + + toast.success('Republishing to optimal relays')}> +
Optimal relays
+
+ + + + {relays.map((relay) => ( + toast.success(`Republishing to ${relay.name}`)} + > +
+ +
{relay.name}
+ + {relay.status} + +
+
+ ))} +
+
+ + + + toast.error('Deleting...')} + className="text-destructive focus:text-destructive" + > + + Delete + +
+
+ ) +} + +// ============================================================================ +// 6. Dynamic Content Example +// ============================================================================ + +export function DynamicContentExample() { + const [canDelete, setCanDelete] = React.useState(true) + const [isPinned, setIsPinned] = React.useState(false) + + return ( +
+
+ + +
+ + + + + + + + toast.success('Copied')}> + + Copy + + + toast.success('Shared')}> + + Share + + + {/* 条件渲染 */} + {isPinned && ( + <> + + setIsPinned(false)}>Unpin + + )} + + {canDelete && ( + <> + + toast.error('Deleted')} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + +
+ ) +} + +// ============================================================================ +// 7. Custom Style Example +// ============================================================================ + +export function CustomStyleExample() { + return ( + + + + + + + toast.success('Copied')}> + + Copy + + + toast.success('Shared')}> + + Share + + + + ) +} diff --git a/src/components/ui/responsive-menu.tsx b/src/components/ui/responsive-menu.tsx new file mode 100644 index 0000000..1e824f9 --- /dev/null +++ b/src/components/ui/responsive-menu.tsx @@ -0,0 +1,480 @@ +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(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(null) + const [subMenuTitle, setSubMenuTitle] = React.useState('') + + 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 ( + + + {React.Children.map(children, (child) => { + if (React.isValidElement(child) && child.type === ResponsiveMenuTrigger) { + const props = child.props as { children: React.ReactNode } + return {props.children} + } + if (React.isValidElement(child) && child.type === ResponsiveMenuContent) { + const props = child.props as { children: React.ReactNode; className?: string } + return ( + <> + + +
+ {subMenuContent ? ( + <> + +
+ {subMenuContent} + + ) : ( + props.children + )} +
+ + + ) + } + return null + })} + + + ) + } + + return ( + + + {children} + + + ) +} + +// ============================================================================ +// 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 {children} +} + +// ============================================================================ +// 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 ( + + {children} + + ) +} + +// ============================================================================ +// Item Component +// ============================================================================ + +interface ResponsiveMenuItemProps { + children: React.ReactNode + onClick?: React.MouseEventHandler + className?: string + disabled?: boolean +} + +export function ResponsiveMenuItem({ + children, + onClick, + className, + disabled +}: ResponsiveMenuItemProps) { + const { isSmallScreen, closeMenu } = useResponsiveMenuContext() + + if (isSmallScreen) { + return ( + + ) + } + + return ( + { + onClick?.(e) + closeMenu() + }} + disabled={disabled} + className={className} + > + {children} + + ) +} + +// ============================================================================ +// 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 ( +
{ + if (disabled) return + onCheckedChange(!checked) + }} + className={cn( + 'flex items-center gap-2 px-4 py-3 clickable', + disabled && 'opacity-50 pointer-events-none', + className + )} + > +
+ {checked && } +
+ {children} +
+ ) + } + + return ( + e.preventDefault()} + onCheckedChange={onCheckedChange} + disabled={disabled} + className={cn('flex items-center gap-2', className)} + > + {children} + + ) +} + +// ============================================================================ +// Separator Component +// ============================================================================ + +interface ResponsiveMenuSeparatorProps { + className?: string +} + +export function ResponsiveMenuSeparator({ className }: ResponsiveMenuSeparatorProps) { + const { isSmallScreen } = useResponsiveMenuContext() + + if (isSmallScreen) { + return + } + + return +} + +// ============================================================================ +// Label Component +// ============================================================================ + +interface ResponsiveMenuLabelProps { + children: React.ReactNode + className?: string +} + +export function ResponsiveMenuLabel({ children, className }: ResponsiveMenuLabelProps) { + const { isSmallScreen } = useResponsiveMenuContext() + + if (isSmallScreen) { + return ( +
+ {children} +
+ ) + } + + return {children} +} + +// ============================================================================ +// 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(null) + const [title, setTitle] = React.useState('') + + 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 ( + + + {children} + + + ) + } + + return {children} +} + +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 ( + + ) + } + + return {children} +} + +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 ( + + {children} + + ) +}