From 3eb018f39f1bc83d062772fd167c666d909a7883 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 29 Nov 2025 12:06:13 +0800 Subject: [PATCH] Revert "refactor: responsive menu" This reverts commit 1dc18645b27dcc02320a5bf873292f299e2e9701. --- 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, 1097 insertions(+), 1324 deletions(-) create mode 100644 src/components/NoteOptions/DesktopMenu.tsx create mode 100644 src/components/NoteOptions/MobileMenu.tsx delete mode 100644 src/components/ui/responsive-menu.example.tsx delete mode 100644 src/components/ui/responsive-menu.tsx diff --git a/src/components/ExternalLink/index.tsx b/src/components/ExternalLink/index.tsx index b1a07c1..e622355 100644 --- a/src/components/ExternalLink/index.tsx +++ b/src/components/ExternalLink/index.tsx @@ -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 ( { - e?.stopPropagation() - window.open(url, '_blank', 'noreferrer') - } + const trigger = ( + { + e.stopPropagation() + if (isSmallScreen) { + setIsDrawerOpen(true) + } + }} + title={url} + > + {displayUrl} + + ) - const handleViewDiscussions = (e?: React.MouseEvent) => { - e?.stopPropagation() - setTimeout(() => push(toExternalContent(url)), 100) // wait for menu to close + if (isSmallScreen) { + return ( + <> + {trigger} + + { + e.stopPropagation() + setIsDrawerOpen(false) + }} + /> + +
+ + +
+
+
+ + ) } return ( -
e.stopPropagation()}> - - - - {displayUrl} - - - - - - {t('Open link')} - - - - {t('View Nostr discussions')} - - - -
+ + + + {displayUrl} + + + e.stopPropagation()}> + + + {t('Open link')} + + + + {t('View Nostr discussions')} + + + ) } diff --git a/src/components/FavoriteRelaysSetting/RelaySet.tsx b/src/components/FavoriteRelaysSetting/RelaySet.tsx index 4ce2155..d52387d 100644 --- a/src/components/FavoriteRelaysSetting/RelaySet.tsx +++ b/src/components/FavoriteRelaysSetting/RelaySet.tsx @@ -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 = ( + + ) + const rename = () => { setRenamingRelaySetId(relaySet.id) } @@ -149,30 +159,53 @@ 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 5359cc7..651c3a9 100644 --- a/src/components/MuteButton/index.tsx +++ b/src/components/MuteButton/index.tsx @@ -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 ( - - - - + const trigger = ( + + ) - - + {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 new file mode 100644 index 0000000..c010718 --- /dev/null +++ b/src/components/NoteOptions/DesktopMenu.tsx @@ -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 ( + + {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 new file mode 100644 index 0000000..a2e66ce --- /dev/null +++ b/src/components/NoteOptions/MobileMenu.tsx @@ -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} + + + +
+ {!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 8648d51..eb4b7a6 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -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([]) + 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 = ( + + ) + return (
e.stopPropagation()}> - - - - - - - {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} - - )} -
- ) - })} -
-
+ {isSmallScreen ? ( + + ) : ( + + )} 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:
{t('Optimal relays')}
, 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:
{set.name}
, onClick: async () => { + closeDrawer() const promise = client.publishEvent(set.relayUrls, event) toast.promise(promise, { loading: t('Republishing...'), @@ -129,6 +137,7 @@ export function useMenuActions({
), 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 diff --git a/src/components/PostEditor/Mentions.tsx b/src/components/PostEditor/Mentions.tsx index c90f5f8..27f1d58 100644 --- a/src/components/PostEditor/Mentions.tsx +++ b/src/components/PostEditor/Mentions.tsx @@ -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([]) @@ -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 ( + { + 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} +
+
+
+ + ) + } 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} - > - - - - ) - })} - - + + + {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} + ) } diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 4b8c3cc..820abde 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -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> }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() + const [isDrawerOpen, setIsDrawerOpen] = useState(false) const { relayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() const [postTargetItems, setPostTargetItems] = useState([]) @@ -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 ( - - -
- - -
-
- - - item.type === 'writeRelays')} + const content = useMemo(() => { + 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) => 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) => 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 7bca365..8b825d3 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -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 ( - - - - + const trigger = ( + + ) - - navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}> + if (isSmallScreen) { + return ( + <> + {trigger} + + setIsDrawerOpen(false)} /> + +
+ + {accountPubkey ? ( + isMuted ? ( + + ) : ( + <> + + + + ) + ) : null} +
+
+
+ + ) + } + + return ( + + {trigger} + + 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 907501d..304c303 100644 --- a/src/components/SaveRelayDropdownMenu/index.tsx +++ b/src/components/SaveRelayDropdownMenu/index.tsx @@ -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 ? ( + + ) : ( + + ) + + if (isSmallScreen) { + return ( +
+ {trigger} +
e.stopPropagation()}> + + setIsDrawerOpen(false)} /> + + + {t('Save to')} ... + +
+ + {relaySets.map((set) => ( + + ))} + + +
+
+
+
+
+ ) + } return ( -
e.stopPropagation()}> - - - {bigButton ? ( - - ) : ( - - )} - - - {t('Save to')} ... - - - {relaySets.map((set) => ( - - ))} - - - - -
+ + + {trigger} + + e.stopPropagation()}> + {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)), @@ -77,15 +122,25 @@ 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)) @@ -108,16 +163,26 @@ 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() @@ -132,10 +197,19 @@ 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 eadd5ad..26e905f 100644 --- a/src/components/Sidebar/AccountButton.tsx +++ b/src/components/Sidebar/AccountButton.tsx @@ -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 ( - - + + - - - push(toWallet())}> + + + push(toWallet())}> {t('Wallet')} - - - {t('Switch account')} + + + {t('Switch account')} {accounts.map((act) => ( - { @@ -92,17 +92,18 @@ function ProfileButton({ collapse }: { collapse: boolean }) { act.pubkey === pubkey && 'size-4 border-4 border-primary' )} /> - + ))} -
- setLoginDialogOpen(true)}> -
- - {t('Add an Account')} -
-
-
- setLoginDialogOpen(true)} + className="border border-dashed m-2 focus:border-muted-foreground focus:bg-background" + > +
+ + {t('Add an Account')} +
+ + setLogoutDialogOpen(true)} > @@ -110,13 +111,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) { {t('Logout')} -
-
+ + -
+ ) } diff --git a/src/components/StuffStats/RepostButton.tsx b/src/components/StuffStats/RepostButton.tsx index f976101..0ca3be4 100644 --- a/src/components/StuffStats/RepostButton.tsx +++ b/src/components/StuffStats/RepostButton.tsx @@ -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 = ( + + ) + + if (!event) { + return trigger + } + + const postEditor = ( + + ) + + if (isSmallScreen) { + return ( + <> + {trigger} + + setIsDrawerOpen(false)} /> + +
+ + +
+
+
+ {postEditor} + + ) + } + return ( <> - - - - - - - { - e?.stopPropagation() repost() }} disabled={!canRepost} > {t('Repost')} - - + { e.stopPropagation() checkLogin(() => { @@ -111,15 +171,10 @@ 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 ea6f196..84d3b49 100644 --- a/src/components/StuffStats/SeenOnButton.tsx +++ b/src/components/StuffStats/SeenOnButton.tsx @@ -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([]) + const [isDrawerOpen, setIsDrawerOpen] = useState(false) useEffect(() => { if (!event) return const seenOn = client.getSeenEventRelayUrls(event.id) setRelays(seenOn) - }, [event]) + }, []) + + const trigger = ( + + ) if (relays.length === 0) { + return trigger + } + + if (isSmallScreen) { return ( - + <> + {trigger} + + setIsDrawerOpen(false)} /> + +
+ {relays.map((relay) => ( + + ))} +
+
+
+ ) } return ( - - - - - - - {t('Seen on')} - + + {trigger} + + {t('Seen on')} + {relays.map((relay) => ( - { - setTimeout(() => push(toRelay(relay)), 100) // slight delay to allow menu to close - }} - > + push(toRelay(relay))} className="min-w-52"> {simplifyUrl(relay)} - + ))} - - + + ) } diff --git a/src/components/ui/responsive-menu.example.tsx b/src/components/ui/responsive-menu.example.tsx deleted file mode 100644 index 0902fbe..0000000 --- a/src/components/ui/responsive-menu.example.tsx +++ /dev/null @@ -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 ( - - - - - - - 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 deleted file mode 100644 index 1e824f9..0000000 --- a/src/components/ui/responsive-menu.tsx +++ /dev/null @@ -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(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} - - ) -}