Files
joke-app/apps/web/src/components/PlayerCard.tsx
EJClaw 43156a9f63 방: 나가기 버튼을 막대로 빼고 ← 홈도 나가기로, 이름 눌러 기록 카드, 게스트 경고, 관리자 계정별 기록
- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고,
  게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다.
- 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계).
  GET /api/users/:id/stats (로그인 필요).
- 게스트 경고: 이름 정하기, 로그인 페이지, /me, 게스트가 있는 대기실.
- 관리자 사용자 상세: 전체 기록, 게임별 기록 표, 최근 게임(GET /api/admin/users/:id/games). 목록에 전적 열(PC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:08 +09:00

115 lines
5.2 KiB
TypeScript

/** 방에서 사람 이름을 누르면 뜨는 기록 카드 (docs/06 §5): 이 게임의 판·승·패·무와 전체 합계. */
import { useEffect, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { isBotId, type MyGameSummary, type PublicUser } from '@bg/shared/lite';
import { api, type GameStat } from '../net/api';
import { Avatar, Sheet } from './ui';
import { gameName } from './GameNotice';
/** 패 = 끝까지 한 판 - 승 - 무 (1등이 아니면 패로 센다). */
export function record(s: Pick<GameStat, 'played' | 'wins' | 'draws'>) {
const losses = Math.max(0, s.played - s.wins - s.draws);
const rate = s.played ? Math.round((s.wins / s.played) * 100) : null;
return { played: s.played, wins: s.wins, draws: s.draws, losses, rate };
}
export function sumStats(stats: GameStat[]) {
return stats.reduce((t, s) => ({ played: t.played + s.played, wins: t.wins + s.wins, draws: t.draws + s.draws }), { played: 0, wins: 0, draws: 0 });
}
export function recordText(s: Pick<GameStat, 'played' | 'wins' | 'draws'>): string {
const r = record(s);
return `${r.played}판 ${r.wins}승 ${r.losses}패 ${r.draws}무${r.rate !== null ? ` (승률 ${r.rate}%)` : ''}`;
}
/** One finished game from the player's side (내 게임 기록, 관리자 사용자 기록). */
export function resultLabel(g: MyGameSummary): string {
if (g.endReason === 'void') return '무효';
if (g.myRank === null) return '끝남';
if (g.players.length === 1) return '혼자 했어요';
if (g.players.every((p) => p.rank === 1)) return '무승부';
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
return `${g.myRank}등`;
}
/** A player's name that opens their record card. Computer players are plain text. */
export function PlayerName({ user, gameId, session, children }: { user: PublicUser; gameId: string; session?: { played: number; wins: number }; children?: ReactNode }) {
const [open, setOpen] = useState(false);
if (isBotId(user.id)) return <>{children ?? user.nickname}</>;
return (
<>
<button type="button" className="name-btn" onClick={() => setOpen(true)} aria-label={`${user.nickname} 기록 보기`}>
{children ?? user.nickname}
</button>
{/* Portal: the name sits in one-line, bold text that the card must not inherit. */}
{open && createPortal(<PlayerCard user={user} gameId={gameId} session={session} onClose={() => setOpen(false)} />, document.body)}
</>
);
}
function PlayerCard({ user, gameId, session, onClose }: { user: PublicUser; gameId: string; session?: { played: number; wins: number }; onClose: () => void }) {
const [stats, setStats] = useState<GameStat[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.userStats(user.id).then(
(r) => setStats(r.stats),
(e) => setError((e as Error).message),
);
}, [user.id]);
const name = gameName(gameId);
const here = stats ? record(stats.find((s) => s.gameId === gameId) ?? { played: 0, wins: 0, draws: 0 }) : null;
const total = stats ? sumStats(stats) : null;
return (
<Sheet onClose={onClose} label={`${user.nickname} 기록`}>
<div className="row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={user} size={52} />
<div style={{ minWidth: 0 }}>
<h2 style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{user.nickname}</h2>
<span className="badge">{user.kind === 'member' ? '디스코드 회원' : '게스트'}</span>
</div>
</div>
{error ? (
<p className="field-error">{error}</p>
) : !here || !total ? (
<p className="muted">기록을 불러오는 중…</p>
) : (
<>
<section className="stack" style={{ gap: 8 }} aria-label={`${name} 기록`}>
<strong>{name} 기록</strong>
{here.played === 0 ? (
<p className="muted">아직 이 게임을 끝까지 한 적이 없어요.</p>
) : (
<div className="record-grid">
<RecordCell label="판" value={here.played} />
<RecordCell label="승" value={here.wins} />
<RecordCell label="패" value={here.losses} />
<RecordCell label="무" value={here.draws} />
<RecordCell label="승률" value={`${here.rate}%`} />
</div>
)}
</section>
{session && session.played > 0 && (
<p className="small">
이 방에서 {session.played}판 {session.wins}승
</p>
)}
<p className="muted small">전체 게임 {total.played ? recordText(total) : '기록 없음'}</p>
{user.kind === 'guest' && <p className="muted small">게스트 기록은 사이트에 들어오지 않은 채 하루가 지나면 계정과 함께 지워져요.</p>}
</>
)}
<button className="btn btn-secondary btn-block" onClick={onClose}>
닫기
</button>
</Sheet>
);
}
export function RecordCell({ label, value }: { label: string; value: number | string }) {
return (
<div className="record-cell">
<span className="muted small">{label}</span>
<strong>{value}</strong>
</div>
);
}