mirror of
https://github.com/ershisan99/www.git
synced 2025-12-17 21:09:22 +00:00
299 lines
7.9 KiB
TypeScript
299 lines
7.9 KiB
TypeScript
'use client'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
import type { SelectGames } from '@/server/db/types'
|
|
import type { LeaderboardEntry } from '@/server/services/neatqueue.service'
|
|
import { RANKED_CHANNEL } from '@/shared/constants'
|
|
import { api } from '@/trpc/react'
|
|
import { Swords } from 'lucide-react'
|
|
import { useParams } from 'next/navigation'
|
|
import { type ComponentPropsWithoutRef, useEffect, useState } from 'react'
|
|
|
|
function getPlayerData(
|
|
playerLeaderboardEntry: LeaderboardEntry,
|
|
games: SelectGames[]
|
|
) {
|
|
const filteredGamesByLeaderboard = games.filter(
|
|
(game) => game.gameType === 'ranked'
|
|
)
|
|
|
|
const games_played = filteredGamesByLeaderboard.length
|
|
let wins = 0
|
|
let losses = 0
|
|
let ties = 0
|
|
for (const game of filteredGamesByLeaderboard) {
|
|
if (game.result === 'win') {
|
|
wins++
|
|
} else if (game.result === 'loss') {
|
|
losses++
|
|
} else if (game.result === 'tie') {
|
|
ties++
|
|
} else {
|
|
ties++
|
|
}
|
|
}
|
|
|
|
const lastGame = filteredGamesByLeaderboard.at(0)
|
|
const currentName = lastGame?.playerName
|
|
const meaningful_games = games_played - ties
|
|
|
|
return {
|
|
username: currentName,
|
|
games: games_played,
|
|
meaningful_games,
|
|
wins,
|
|
losses,
|
|
ties,
|
|
winRate:
|
|
meaningful_games > 0 ? Math.ceil((wins / meaningful_games) * 100) : 0,
|
|
lossRate:
|
|
meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0,
|
|
rank: playerLeaderboardEntry.rank,
|
|
mmr: Math.round(playerLeaderboardEntry.mmr),
|
|
mmrChangeRaw: lastGame?.mmrChange,
|
|
mmrChange: `${(lastGame?.mmrChange ?? 0) >= 0 ? '+' : ''}${Math.round(
|
|
lastGame?.mmrChange ?? 0
|
|
)}`,
|
|
streak: playerLeaderboardEntry?.streak,
|
|
}
|
|
}
|
|
|
|
export function StreamCardClient() {
|
|
const { id } = useParams()
|
|
if (!id || typeof id !== 'string') {
|
|
return null
|
|
}
|
|
|
|
const [gamesQueryResult, gamesQuery] =
|
|
api.history.user_games.useSuspenseQuery({ user_id: id })
|
|
const games = gamesQueryResult || []
|
|
|
|
const [rankedUserRank, rankedUserQuery] =
|
|
api.leaderboard.get_user_rank.useSuspenseQuery({
|
|
channel_id: RANKED_CHANNEL,
|
|
user_id: id,
|
|
})
|
|
|
|
const result = api.playerState.onStateChange.useSubscription(
|
|
{ userId: id },
|
|
{
|
|
onData: async () => {
|
|
await Promise.all([gamesQuery.refetch(), rankedUserQuery.refetch()])
|
|
},
|
|
}
|
|
)
|
|
|
|
const playerState = result.data?.data
|
|
|
|
if (!rankedUserRank || !games?.length) {
|
|
return null
|
|
}
|
|
|
|
const playerData = getPlayerData(rankedUserRank, games)
|
|
|
|
const isQueuing = playerState?.status === 'queuing'
|
|
const opponentId = playerState?.currentMatch?.opponentId
|
|
|
|
let winsVsOpponent = 0
|
|
let lossesVsOpponent = 0
|
|
if (opponentId) {
|
|
for (const game of games) {
|
|
if (game.opponentId === opponentId) {
|
|
if (game.result === 'win') {
|
|
winsVsOpponent++
|
|
} else if (game.result === 'loss') {
|
|
lossesVsOpponent++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
console.log(winsVsOpponent, lossesVsOpponent)
|
|
return (
|
|
<div
|
|
className={'flex items-center justify-between gap-2 font-m6x11'}
|
|
style={{ zoom: '200%' }}
|
|
>
|
|
<PlayerInfo
|
|
playerData={playerData}
|
|
isInBattle={!!opponentId}
|
|
wins={winsVsOpponent}
|
|
>
|
|
{isQueuing && playerState.queueStartTime && (
|
|
<QueueTimer startTime={playerState.queueStartTime} />
|
|
)}
|
|
</PlayerInfo>
|
|
{opponentId && (
|
|
<>
|
|
<span>
|
|
<Swords />
|
|
</span>{' '}
|
|
<Opponent id={opponentId} wins={lossesVsOpponent} />
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function formatDuration(ms: number): string {
|
|
const seconds = Math.floor(ms / 1000)
|
|
const minutes = Math.floor(seconds / 60)
|
|
const hours = Math.floor(minutes / 60)
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`
|
|
}
|
|
return `${seconds}s`
|
|
}
|
|
|
|
function QueueTimer({ startTime }: { startTime: number }) {
|
|
const [queueTime, setQueueTime] = useState(Date.now() - startTime)
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setQueueTime(Date.now() - startTime)
|
|
}, 1000)
|
|
return () => clearInterval(interval)
|
|
})
|
|
|
|
return (
|
|
<div className='flex animate-pulse items-center gap-1.5 border-slate-700 px-2'>
|
|
<div>Queueing for </div>
|
|
<div className='font-bold text-emerald-400'>
|
|
{formatDuration(queueTime)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Opponent({ id, wins }: { id: string; wins?: number }) {
|
|
const { data: gamesQueryResult } = api.history.user_games.useQuery({
|
|
user_id: id,
|
|
})
|
|
const games = gamesQueryResult || []
|
|
|
|
const { data: rankedUserRank } = api.leaderboard.get_user_rank.useQuery({
|
|
channel_id: RANKED_CHANNEL,
|
|
user_id: id,
|
|
})
|
|
if (!rankedUserRank || !games?.length) {
|
|
return null
|
|
}
|
|
|
|
const playerData = getPlayerData(rankedUserRank, games)
|
|
return <PlayerInfo playerData={playerData} isReverse isInBattle wins={wins} />
|
|
}
|
|
|
|
function PlayerInfo({
|
|
playerData,
|
|
className,
|
|
children,
|
|
isReverse = false,
|
|
isInBattle = false,
|
|
wins,
|
|
...rest
|
|
}: {
|
|
playerData: ReturnType<typeof getPlayerData>
|
|
isReverse?: boolean
|
|
isInBattle?: boolean
|
|
wins?: number
|
|
} & ComponentPropsWithoutRef<'div'>) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex h-10 w-fit max-w-[800px] items-center overflow-hidden border-2 border-slate-800 bg-slate-900/90 text-white shadow-lg backdrop-blur-sm',
|
|
isReverse ? 'flex-row-reverse' : 'flex-row',
|
|
className
|
|
)}
|
|
{...rest}
|
|
>
|
|
<div className='flex aspect-square h-full items-center justify-center gap-1 border-slate-700 border-r bg-gradient-to-r from-indigo-600 to-purple-600 px-2'>
|
|
<span className='font-bold text-sm'>#{playerData.rank}</span>
|
|
</div>
|
|
|
|
{/* Player Name */}
|
|
<div
|
|
className={cn(
|
|
'max-w-[180px] flex-shrink-0 border-slate-700 px-2',
|
|
!isReverse && 'border-r'
|
|
)}
|
|
>
|
|
<div className='truncate font-medium'>{playerData.username}</div>
|
|
</div>
|
|
|
|
{/* MMR */}
|
|
<div className='flex items-center gap-1.5 border-slate-700 border-r px-2'>
|
|
<div>MMR:</div>
|
|
<div className='font-bold'>{playerData.mmr}</div>
|
|
<div
|
|
className={cn(
|
|
'!text-emerald-400 text-sm',
|
|
playerData.mmrChangeRaw &&
|
|
playerData.mmrChangeRaw < 0 &&
|
|
'!text-rose-400'
|
|
)}
|
|
>
|
|
{playerData.mmrChange}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Win Rate */}
|
|
{!isInBattle && (
|
|
<div className='flex items-center gap-1.5 text-nowrap border-slate-700 border-r px-2'>
|
|
<div className='text-nowrap'>WR:</div>
|
|
<div className='text font-bold text-emerald-400'>
|
|
{playerData.winRate}%
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Win/Loss */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-0.5 border-slate-700 px-2',
|
|
isReverse && 'border-r'
|
|
)}
|
|
>
|
|
<div className='flex items-center'>
|
|
<div className='ml-0.5 font-bold text-emerald-400'>
|
|
{playerData.wins}W
|
|
</div>
|
|
</div>
|
|
|
|
|
<div className='flex items-center'>
|
|
<div className='ml-0.5 font-bold text-rose-400'>
|
|
{playerData.losses}L
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isInBattle && (
|
|
<div
|
|
className={cn(
|
|
'flex aspect-square h-full items-center justify-center gap-1 border-slate-700 bg-gradient-to-r px-2',
|
|
isReverse ? 'mr-4 border-r' : 'ml-4 border-l'
|
|
)}
|
|
>
|
|
<span className='font-bold text-sm'>{wins}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Streak */}
|
|
{!isInBattle && (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1.5 border-slate-700 px-2',
|
|
(children || isReverse) && 'border-r'
|
|
)}
|
|
>
|
|
<div>Streak:</div>
|
|
<div className='font-bold text-emerald-400'>{playerData.streak}</div>
|
|
</div>
|
|
)}
|
|
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|