diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 5622121bc..fe49b51e1 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -40,6 +40,11 @@ import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { type RouterInput } from '@/types/trpc' import { filterNullAndEmpty } from '@/utils/filter' +import { + isAnchorNavigationTarget, + openInNewTab, + shouldOpenInNewTab, +} from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' import { ms } from '@/utils/time' @@ -516,149 +521,171 @@ function ListingsPage() { listingsQuery.isFetching && 'opacity-50', )} > - {listingsQuery.data?.listings.map((listing) => ( - router.push(`/listings/${listing.id}`)} - > - {columnVisibility.isColumnVisible('game') && ( - -
- - - - {listing.game.title.substring(0, 30)} - {listing.game.title.length > 30 && '...'} - - - {listing.game.title} - - - {listing.status === ApprovalStatus.PENDING && ( + {listingsQuery.data?.listings.map((listing) => { + const listingHref = `/listings/${listing.id}` + + return ( + { + if (isAnchorNavigationTarget(event)) return + + if (shouldOpenInNewTab(event)) { + event.preventDefault() + openInNewTab(listingHref) + return + } + + router.push(listingHref) + }} + onAuxClick={(event) => { + if (isAnchorNavigationTarget(event) || !shouldOpenInNewTab(event)) return + + event.preventDefault() + openInNewTab(listingHref) + }} + > + {columnVisibility.isColumnVisible('game') && ( + +
- - + + + {listing.game.title.substring(0, 30)} + {listing.game.title.length > 30 && '...'} + - Under Review + {listing.game.title} - )} - + {listing.status === ApprovalStatus.PENDING && ( + + + + + Under Review + + )} + + - {listing.developerVerifications && - listing.developerVerifications.length > 0 && ( - 0 && ( + + )} +
+ + )} + {columnVisibility.isColumnVisible('system') && ( + + {isSystemIconsHydrated && + showSystemIcons && + listing.game.system?.key ? ( +
+ - )} -
- - )} - {columnVisibility.isColumnVisible('system') && ( - - {isSystemIconsHydrated && showSystemIcons && listing.game.system?.key ? ( +
+ ) : ( + (listing.game.system?.name ?? 'Unknown') + )} + + )} + {columnVisibility.isColumnVisible('device') && ( + + {listing.device + ? `${listing.device.brand.name} ${listing.device.modelName}` + : 'N/A'} + + )} + {columnVisibility.isColumnVisible('emulator') && ( +
- + {listing.emulator ? ( + + ) : ( + 'N/A' + )} + {listing.isVerifiedDeveloper && }
- ) : ( - (listing.game.system?.name ?? 'Unknown') - )} - - )} - {columnVisibility.isColumnVisible('device') && ( - - {listing.device - ? `${listing.device.brand.name} ${listing.device.modelName}` - : 'N/A'} - - )} - {columnVisibility.isColumnVisible('emulator') && ( - -
- {listing.emulator ? ( - + + )} + {columnVisibility.isColumnVisible('performance') && ( + + + + )} + {columnVisibility.isColumnVisible('successRate') && ( + + + + )} + {columnVisibility.isColumnVisible('author') && ( + + {listing.author?.id ? ( + event.stopPropagation()} + > + {listing.author.name ?? 'Anonymous'} + ) : ( - 'N/A' + {listing.author?.name ?? 'Anonymous'} )} - {listing.isVerifiedDeveloper && } -
- - )} - {columnVisibility.isColumnVisible('performance') && ( - - - - )} - {columnVisibility.isColumnVisible('successRate') && ( - - - - )} - {columnVisibility.isColumnVisible('author') && ( - - {listing.author?.id ? ( - event.stopPropagation()} - > - {listing.author.name ?? 'Anonymous'} - - ) : ( - {listing.author?.name ?? 'Anonymous'} - )} - - )} - {columnVisibility.isColumnVisible('posted') && ( - - - - )} - {columnVisibility.isColumnVisible('actions') && ( - e.stopPropagation()}> -
- {isAdmin && ( - + )} + {columnVisibility.isColumnVisible('posted') && ( + + + + )} + {columnVisibility.isColumnVisible('actions') && ( + e.stopPropagation()}> +
+ {isAdmin && ( + + )} + - )} - -
- - )} - - ))} +
+ + )} + + ) + })} )} diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index f1e47f637..d1fbaf329 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -42,6 +42,11 @@ import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { type RouterInput } from '@/types/trpc' import { filterNullAndEmpty } from '@/utils/filter' +import { + isAnchorNavigationTarget, + openInNewTab, + shouldOpenInNewTab, +} from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' import { Role, ApprovalStatus } from '@orm' @@ -454,154 +459,178 @@ function PcListingsPage() { listingsQuery.isFetching && 'opacity-50', )} > - {listingsQuery.data?.pcListings.map((listing) => ( - router.push(`/pc-listings/${listing.id}`)} - > - {columnVisibility.isColumnVisible('game') && ( - -
- - - - {listing.game.title.substring(0, 30)} - {listing.game.title.length > 30 && '...'} - - - {listing.game.title} - - - {listing.status === ApprovalStatus.PENDING && ( + {listingsQuery.data?.pcListings.map((listing) => { + const listingHref = `/pc-listings/${listing.id}` + + return ( + { + if (isAnchorNavigationTarget(event)) return + + if (shouldOpenInNewTab(event)) { + event.preventDefault() + openInNewTab(listingHref) + return + } + + router.push(listingHref) + }} + onAuxClick={(event) => { + if (isAnchorNavigationTarget(event) || !shouldOpenInNewTab(event)) return + + event.preventDefault() + openInNewTab(listingHref) + }} + > + {columnVisibility.isColumnVisible('game') && ( + +
- - + + + {listing.game.title.substring(0, 30)} + {listing.game.title.length > 30 && '...'} + - Under Review + {listing.game.title} - )} - -
- - )} - {columnVisibility.isColumnVisible('system') && ( - - {isSystemIconsHydrated && showSystemIcons && listing.game.system?.key ? ( -
- + + + + Under Review + + )} + +
- ) : ( - (listing.game.system?.name ?? 'Unknown') - )} - - )} - {columnVisibility.isColumnVisible('cpu') && ( - - {listing.cpu - ? `${listing.cpu.brand.name} ${listing.cpu.modelName}` - : 'N/A'} - - )} - {columnVisibility.isColumnVisible('gpu') && ( - - {listing.gpu - ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` - : 'Integrated'} - - )} - {columnVisibility.isColumnVisible('memory') && ( - - {listing.memorySize}GB - - )} - {columnVisibility.isColumnVisible('os') && ( - {listing.os} - )} - {columnVisibility.isColumnVisible('emulator') && ( - -
- {listing.emulator ? ( - + + )} + {columnVisibility.isColumnVisible('system') && ( + + {isSystemIconsHydrated && + showSystemIcons && + listing.game.system?.key ? ( +
+ +
) : ( - 'N/A' - )} -
- - )} - {columnVisibility.isColumnVisible('performance') && ( - - - - )} - {columnVisibility.isColumnVisible('verified') && ( - - - - )} - {columnVisibility.isColumnVisible('author') && ( - - {listing.author?.id ? ( - event.stopPropagation()} - > - {listing.author.name ?? 'Anonymous'} - - ) : ( - {listing.author?.name ?? 'Anonymous'} - )} - - )} - {columnVisibility.isColumnVisible('posted') && ( - - - - )} - {columnVisibility.isColumnVisible('actions') && ( - e.stopPropagation()}> -
- {isAdmin && ( - + (listing.game.system?.name ?? 'Unknown') )} - + )} + {columnVisibility.isColumnVisible('cpu') && ( + + {listing.cpu + ? `${listing.cpu.brand.name} ${listing.cpu.modelName}` + : 'N/A'} + + )} + {columnVisibility.isColumnVisible('gpu') && ( + + {listing.gpu + ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` + : 'Integrated'} + + )} + {columnVisibility.isColumnVisible('memory') && ( + + {listing.memorySize}GB + + )} + {columnVisibility.isColumnVisible('os') && ( + + {listing.os} + + )} + {columnVisibility.isColumnVisible('emulator') && ( + +
+ {listing.emulator ? ( + + ) : ( + 'N/A' + )} +
+ + )} + {columnVisibility.isColumnVisible('performance') && ( + + -
- - )} - - ))} + + )} + {columnVisibility.isColumnVisible('verified') && ( + + + + )} + {columnVisibility.isColumnVisible('author') && ( + + {listing.author?.id ? ( + event.stopPropagation()} + > + {listing.author.name ?? 'Anonymous'} + + ) : ( + {listing.author?.name ?? 'Anonymous'} + )} + + )} + {columnVisibility.isColumnVisible('posted') && ( + + + + )} + {columnVisibility.isColumnVisible('actions') && ( + e.stopPropagation()}> +
+ {isAdmin && ( + + )} + +
+ + )} + + ) + })} )} diff --git a/src/app/v2/listings/components/ListingCard.tsx b/src/app/v2/listings/components/ListingCard.tsx index 5d9d60ec7..b820e6a9f 100644 --- a/src/app/v2/listings/components/ListingCard.tsx +++ b/src/app/v2/listings/components/ListingCard.tsx @@ -16,6 +16,11 @@ import { } from '@/components/ui' import { cn } from '@/lib/utils' import getGameImageUrl from '@/utils/images/getGameImageUrl' +import { + isAnchorNavigationTarget, + openInNewTab, + shouldOpenInNewTab, +} from '@/utils/navigation-events' import { ApprovalStatus } from '@orm' import type { RouterOutput } from '@/types/trpc' @@ -38,6 +43,8 @@ export function ListingCard({ }: Props) { const router = useRouter() const [isLiked, setIsLiked] = useState(false) + const listingHref = `/listings/${listing.id}` + const gameHref = `/games/${listing.game.id}` const handleLike = () => { setIsLiked(!isLiked) @@ -54,7 +61,33 @@ export function ListingCard({ const navigateToGame = (ev: MouseEvent) => { ev.stopPropagation() - router.push(`/games/${listing.game.id}`) + + if (shouldOpenInNewTab(ev)) { + ev.preventDefault() + openInNewTab(gameHref) + return + } + + router.push(gameHref) + } + + const navigateToListing = (ev: MouseEvent) => { + if (isAnchorNavigationTarget(ev)) return + + if (shouldOpenInNewTab(ev)) { + ev.preventDefault() + openInNewTab(listingHref) + return + } + + router.push(listingHref) + } + + const openListingFromAuxClick = (ev: MouseEvent) => { + if (isAnchorNavigationTarget(ev) || !shouldOpenInNewTab(ev)) return + + ev.preventDefault() + openInNewTab(listingHref) } // Get game cover image or placeholder @@ -76,7 +109,8 @@ export function ListingCard({ router.push(`/listings/${listing.id}`)} + onClick={navigateToListing} + onAuxClick={openListingFromAuxClick} className={cn( 'bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden transition-all duration-300 group cursor-pointer', 'hover:shadow-2xl hover:shadow-gray-200/50 dark:hover:shadow-gray-900/50', @@ -139,6 +173,7 @@ export function ListingCard({ variant="ghost" size="sm" onClick={navigateToGame} + onAuxClick={navigateToGame} className="absolute top-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90 hover:bg-white text-gray-900 shadow-lg" aria-label={`View game: ${listing.game.title}`} > diff --git a/src/components/ui/SwipeableCard.test.tsx b/src/components/ui/SwipeableCard.test.tsx new file mode 100644 index 000000000..d85c470c9 --- /dev/null +++ b/src/components/ui/SwipeableCard.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SwipeableCard } from './SwipeableCard' +import type { MouseEvent } from 'react' + +describe('SwipeableCard', () => { + it('passes click events to the click handler', () => { + const handleClick = vi.fn((event: MouseEvent) => { + expect(event.ctrlKey).toBe(true) + }) + + render(Open report) + + fireEvent.click(screen.getByText('Open report'), { ctrlKey: true }) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('passes middle-click events to the auxiliary click handler', () => { + const handleAuxClick = vi.fn((event: MouseEvent) => { + expect(event.button).toBe(1) + }) + + render(Open report) + + fireEvent( + screen.getByText('Open report'), + new MouseEvent('auxclick', { bubbles: true, button: 1 }), + ) + + expect(handleAuxClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/ui/SwipeableCard.tsx b/src/components/ui/SwipeableCard.tsx index 84cf78e1a..fe0c22ece 100644 --- a/src/components/ui/SwipeableCard.tsx +++ b/src/components/ui/SwipeableCard.tsx @@ -1,14 +1,15 @@ 'use client' import { motion, useMotionValue, useTransform } from 'framer-motion' -import { type PropsWithChildren, useState } from 'react' +import { type PropsWithChildren, type MouseEvent, useState } from 'react' import { cn } from '@/lib/utils' import type { PanInfo } from 'framer-motion' interface Props extends PropsWithChildren { onSwipeLeft?: () => void onSwipeRight?: () => void - onClick?: () => void + onClick?: (e: MouseEvent) => void + onAuxClick?: (e: MouseEvent) => void className?: string swipeThreshold?: number enableHaptics?: boolean @@ -21,40 +22,36 @@ export function SwipeableCard(props: Props) { const [isSwiping, setIsSwiping] = useState(false) const x = useMotionValue(0) - // Transform the card's opacity and rotation based on the swipe distance const opacity = useTransform(x, [-swipeThreshold * 2, 0, swipeThreshold * 2], [0.5, 1, 0.5]) const rotate = useTransform(x, [-swipeThreshold * 2, 0, swipeThreshold * 2], [-8, 0, 8]) - // Handle drag end const handleDragEnd = (_event: unknown, _info: PanInfo) => { const xOffset = x.get() - // Reset position x.set(0) - // If the card was swiped far enough, trigger the appropriate callback if (xOffset < -swipeThreshold && props.onSwipeLeft) { props.onSwipeLeft() - // Trigger haptic feedback if available if (enableHaptics && navigator.vibrate) { navigator.vibrate(50) } } else if (xOffset > swipeThreshold && props.onSwipeRight) { props.onSwipeRight() - // Trigger haptic feedback if available if (enableHaptics && navigator.vibrate) navigator.vibrate(50) } setIsSwiping(false) } - // Handle click - const handleClick = () => { - // Only trigger click if we're not swiping - if (!isSwiping && props.onClick) props.onClick() + const handleClick = (e: MouseEvent) => { + if (!isSwiping && props.onClick) props.onClick(e) + } + + const handleAuxClick = (e: MouseEvent) => { + if (!isSwiping && props.onAuxClick) props.onAuxClick(e) } return ( @@ -72,6 +69,7 @@ export function SwipeableCard(props: Props) { onDragStart={() => setIsSwiping(true)} onDragEnd={handleDragEnd} onClick={handleClick} + onAuxClick={handleAuxClick} whileTap={{ scale: isSwiping ? 1 : 0.98 }} > {props.children} diff --git a/src/utils/navigation-events.test.ts b/src/utils/navigation-events.test.ts new file mode 100644 index 000000000..5880a1093 --- /dev/null +++ b/src/utils/navigation-events.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isAnchorNavigationTarget, + openInNewTab, + shouldOpenInNewTab, + type NavigationClickEvent, +} from './navigation-events' + +function createNavigationEvent( + overrides: Partial = {}, +): NavigationClickEvent { + return { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + target: document.createElement('div'), + ...overrides, + } +} + +describe('navigation-events', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not request a new tab for a normal primary click', () => { + expect(shouldOpenInNewTab(createNavigationEvent())).toBe(false) + }) + + it.each([ + ['Ctrl-click', { ctrlKey: true }], + ['Cmd-click', { metaKey: true }], + ['Shift-click', { shiftKey: true }], + ['middle-click', { button: 1 }], + ])('requests a new tab for %s', (_label, overrides) => { + expect(shouldOpenInNewTab(createNavigationEvent(overrides))).toBe(true) + }) + + it('detects clicks from inside anchor elements', () => { + const anchor = document.createElement('a') + anchor.href = '/listings/123' + + const child = document.createElement('span') + anchor.appendChild(child) + + expect(isAnchorNavigationTarget(createNavigationEvent({ target: child }))).toBe(true) + }) + + it('does not treat non-anchor targets as anchor navigation', () => { + expect(isAnchorNavigationTarget(createNavigationEvent())).toBe(false) + }) + + it('opens URLs in a new tab without passing opener access', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + openInNewTab('/listings/123') + + expect(openSpy).toHaveBeenCalledWith('/listings/123', '_blank', 'noopener,noreferrer') + }) +}) diff --git a/src/utils/navigation-events.ts b/src/utils/navigation-events.ts new file mode 100644 index 000000000..6b2e04fb0 --- /dev/null +++ b/src/utils/navigation-events.ts @@ -0,0 +1,23 @@ +export interface NavigationClickEvent { + button: number + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + target: EventTarget | null +} + +export function shouldOpenInNewTab(event: NavigationClickEvent): boolean { + return event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey +} + +export function isAnchorNavigationTarget(event: NavigationClickEvent): boolean { + if (typeof Element === 'undefined' || !(event.target instanceof Element)) { + return false + } + + return event.target.closest('a[href]') !== null +} + +export function openInNewTab(href: string): void { + window.open(href, '_blank', 'noopener,noreferrer') +}