refactor user table, make opponent name a link

This commit is contained in:
2025-04-04 14:19:15 +02:00
parent 3afa2dc2bf
commit ed5bbbab49
6 changed files with 260 additions and 131 deletions

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-virtual": "^3.13.6",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
@@ -392,8 +393,12 @@
"@tanstack/react-query": ["@tanstack/react-query@5.71.5", "", { "dependencies": { "@tanstack/query-core": "5.71.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-WpxZWy4fDASjY+iAaXB+aY+LC95PQ34W6EWVkjJ0hdzWWbczFnr9nHvHkVDpwdR18I1NO8igNGQJFrLrgyzI8Q=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.2", "", { "dependencies": { "@tanstack/table-core": "8.21.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.6", "", { "dependencies": { "@tanstack/virtual-core": "3.13.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.2", "", {}, "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.6", "", {}, "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg=="],
"@trpc/client": ["@trpc/client@11.0.1", "", { "peerDependencies": { "@trpc/server": "11.0.1", "typescript": ">=5.7.2" } }, "sha512-HvOrvWAXbGBwt4om+NfuxrK4f+ik2aaNIXq7WLrJbEp7U+YXfh5++1a5p4JDaikrvSaObJ389DhYAGWz90xSGw=="],

View File

@@ -49,6 +49,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-virtual": "^3.13.6",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",

View File

@@ -0,0 +1,228 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
import type { SelectGames } from '@/server/db/types'
import {
type SortingState,
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { ArrowDownCircle, ArrowUp, ArrowUpCircle } from 'lucide-react'
import { useFormatter } from 'next-intl'
import Link from 'next/link'
import { useMemo, useState } from 'react'
const numberFormatter = new Intl.NumberFormat('en-US', {
signDisplay: 'exceptZero',
})
const columnHelper = createColumnHelper<SelectGames>()
const useColumns = () => {
const format = useFormatter()
return useMemo(
() => [
columnHelper.accessor('opponentName', {
meta: { className: 'pl-4' },
header: 'Opponent',
cell: (info) => (
<Link
href={`/players/${info.row.original.opponentId}`}
className='pl-4 font-medium hover:underline'
>
{info.getValue()}
</Link>
),
}),
columnHelper.accessor('gameType', {
header: 'Game Type',
cell: (info) => {
const gameType = info.getValue()
return (
<Badge
variant='outline'
className={cn(
'font-normal capitalize',
gameType === 'ranked'
? 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950 dark:text-violet-300'
: gameType.toLowerCase() === 'vanilla'
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300'
: 'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-700 dark:text-zinc-300'
)}
>
{info.getValue()}
</Badge>
)
},
}),
columnHelper.accessor('opponentMmr', {
header: 'Opponent MMR',
meta: { className: 'justify-end' },
cell: (info) => (
<span className='flex w-full justify-end font-mono'>
{Math.trunc(info.getValue())}
</span>
),
}),
columnHelper.accessor('playerMmr', {
header: 'MMR',
meta: { className: 'justify-end' },
cell: (info) => (
<span className='flex w-full justify-end font-mono'>
{Math.trunc(info.getValue())}
</span>
),
}),
columnHelper.accessor('mmrChange', {
header: 'Result',
meta: { className: 'justify-end' },
cell: (info) => {
const mmrChange = info.getValue()
return (
<span
className={cn(
'flex items-center justify-end font-medium font-mono',
mmrChange > 0 ? 'text-emerald-500' : 'text-rose-500'
)}
>
{numberFormatter.format(Math.trunc(mmrChange))}
{mmrChange > 0 ? (
<ArrowUpCircle className='ml-1 h-4 w-4' />
) : (
<ArrowDownCircle className='ml-1 h-4 w-4' />
)}
</span>
)
},
}),
columnHelper.accessor('gameTime', {
header: 'Date',
meta: { className: 'justify-end' },
cell: (info) => (
<span
className={'flex items-center justify-end font-medium font-mono'}
>
{format.dateTime(info.getValue(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})}
</span>
),
}),
columnHelper.accessor('gameTime', {
header: 'Time',
meta: { className: 'justify-end pr-4' },
cell: (info) => (
<span
className={
'flex items-center justify-end pr-4 font-medium font-mono'
}
>
{format.dateTime(info.getValue(), {
hour: '2-digit',
minute: '2-digit',
})}
</span>
),
id: 'time',
}),
],
[]
)
}
export function GamesTable({ games }: { games: SelectGames[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const columns = useColumns()
const table = useReactTable({
data: games,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (originalRow) => originalRow.gameNum.toString(),
})
return (
<div className='rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sortDirection = header.column.getIsSorted()
return (
<TableHead key={header.id} className={'px-0'}>
<span
className={cn(
'flex w-full items-center',
(header.column.columnDef.meta as any)?.className
)}
>
<Button
className={cn(
header.column.getCanSort() &&
'cursor-pointer select-none',
(
header.column.columnDef.meta as any
)?.className?.includes('justify-end') &&
'flex-row-reverse'
)}
size={'table'}
variant='ghost'
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{sortDirection ? (
<ArrowUp
className={cn(
'transition-transform',
sortDirection === 'asc' ? 'rotate-180' : ''
)}
/>
) : (
<div className={'h-4 w-4'} />
)}
</Button>
</span>
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -3,6 +3,7 @@
import type React from 'react'
import { useState } from 'react'
import { GamesTable } from '@/app/players/[id]/_components/games-table'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
@@ -13,14 +14,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
@@ -29,10 +22,8 @@ import {
ArrowDownCircle,
ArrowUpCircle,
BarChart3,
Calendar,
ChevronDown,
ChevronUp,
Clock,
Filter,
MinusCircle,
Star,
@@ -342,107 +333,7 @@ export function UserInfo() {
<TabsContent value='matches' className='m-0'>
<div className='overflow-hidden rounded-lg border'>
<div className='overflow-x-auto'>
<Table>
<TableHeader>
<TableRow className='bg-gray-50 dark:bg-zinc-800/50'>
<TableHead className='w-[100px]'>Game Type</TableHead>
<TableHead>Opponent</TableHead>
<TableHead className='text-right'>
Opponent MMR
</TableHead>
<TableHead className='text-right'>MMR</TableHead>
<TableHead className='text-right'>Result</TableHead>
<TableHead className='text-center'>
Leaderboard
</TableHead>
<TableHead className='text-right'>
<span className='flex items-center justify-end gap-1'>
<Calendar className='h-4 w-4' /> Date
</span>
</TableHead>
<TableHead className='text-right'>
<span className='flex items-center justify-end gap-1'>
<Clock className='h-4 w-4' /> Time
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredGames.map((game) => (
<TableRow
key={game.gameId}
className='transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70'
>
<TableCell>
<Badge
variant='outline'
className='font-normal capitalize'
>
{game.gameType}
</Badge>
</TableCell>
<TableCell className='font-medium'>
{game.opponentName}
</TableCell>
<TableCell className='text-right font-mono'>
{Math.trunc(game.opponentMmr)}
</TableCell>
<TableCell className='text-right font-mono'>
{Math.trunc(game.playerMmr)}
</TableCell>
<TableCell className='text-right font-mono'>
{game.mmrChange > 0 ? (
<span className='flex items-center justify-end font-medium text-emerald-500'>
{numberFormatter.format(
Math.trunc(game.mmrChange)
)}
<ArrowUpCircle className='ml-1 inline h-4 w-4' />
</span>
) : (
<span className='flex items-center justify-end font-medium text-rose-500'>
{numberFormatter.format(
Math.trunc(game.mmrChange)
)}
<ArrowDownCircle className='ml-1 inline h-4 w-4' />
</span>
)}
</TableCell>
<TableCell className='text-center'>
<Badge
variant='outline'
className={cn(
'w-full font-normal',
game.gameType === 'ranked'
? 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950 dark:text-violet-300'
: game.gameType.toLowerCase() === 'vanilla'
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300'
: 'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-700 dark:text-zinc-300'
)}
>
{game.gameType === 'ranked'
? 'Ranked Queue'
: game.gameType.toLowerCase() === 'vanilla'
? 'Vanilla Queue'
: 'N/A'}
</Badge>
</TableCell>
<TableCell className='text-right font-mono text-slate-500 dark:text-slate-400'>
{format.dateTime(game.gameTime, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})}
</TableCell>
<TableCell className='text-right font-mono text-slate-500 dark:text-slate-400'>
{format.dateTime(game.gameTime, {
hour: '2-digit',
minute: '2-digit',
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<GamesTable games={games} />
</div>
</div>
</TabsContent>

View File

@@ -1,36 +1,37 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive`,
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
table: 'h-9 px-2 py-2',
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
@@ -41,15 +42,15 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>

3
src/server/db/types.ts Normal file
View File

@@ -0,0 +1,3 @@
import type { player_games } from '@/server/db/schema'
export type SelectGames = typeof player_games.$inferSelect