방: 나가기 버튼을 막대로 빼고 ← 홈도 나가기로, 이름 눌러 기록 카드, 게스트 경고, 관리자 계정별 기록

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 11:34:08 +09:00
parent f93ff4f656
commit 43156a9f63
17 changed files with 462 additions and 39 deletions

View File

@@ -65,7 +65,19 @@ export function NicknameForm({
디스코드로 로그인
</a>
)}
<GuestWarning />
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
</form>
);
}
/** Guests are deleted with their records a day after they leave the site (docs/05 §4). */
export function GuestWarning() {
const discord = useSession((s) => s.discordEnabled);
return (
<p className="guest-warning small" role="note">
⚠️ 게스트는 기록이 오래 저장되지 않아요. 사이트에 들어오지 않은 채 하루가 지나면 계정과 기록이 모두 지워져요.
{discord && ' 기록을 남기려면 디스코드로 로그인해 주세요.'}
</p>
);
}

View File

@@ -0,0 +1,114 @@
/** 방에서 사람 이름을 누르면 뜨는 기록 카드 (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>
);
}

View File

@@ -46,6 +46,13 @@ export interface MeResponse {
activeRoom?: string | null;
}
export interface GameStat {
gameId: string;
played: number;
wins: number;
draws: number;
}
export interface RoomPreview {
code: string;
codeLabel: string;
@@ -66,6 +73,8 @@ export const api = {
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
/** Another player's record (room player card). */
userStats: (id: string) => call<{ user: PublicUser; stats: GameStat[] }>('GET', `/api/users/${encodeURIComponent(id)}/stats`),
myGames: (before?: string | null) =>
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),

View File

@@ -4,7 +4,7 @@
*/
import { Link, Navigate, useMatch, useNavigate, useSearchParams } from 'react-router';
import { TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { GuestWarning, NicknameForm } from '../components/NicknameForm';
import { discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
@@ -58,7 +58,8 @@ export default function LoginPage() {
<Link className={`btn btn-block ${discord ? 'btn-secondary' : 'btn-primary'}`} to={`/login/guest${query}`}>
게스트로 시작하기
</Link>
<p className="muted small">가입 없이 이름만 정하면 돼요. 기록은 이 기기에만 남아요.</p>
<p className="muted small">가입 없이 이름만 정하면 돼요.</p>
<GuestWarning />
</section>
</main>
</>

View File

@@ -3,6 +3,8 @@ import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { GuestWarning } from '../components/NicknameForm';
import { resultLabel } from '../components/PlayerCard';
import { gameName } from '../components/GameNotice';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
@@ -98,6 +100,7 @@ export default function MePage() {
</>
)}
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
{me.kind === 'guest' && <GuestWarning />}
{me.kind === 'guest' && discordEnabled && (
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
디스코드 연결하기 (기록을 안전하게 보관)
@@ -201,15 +204,6 @@ export default function MePage() {
);
}
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}등`;
}
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
function MyGames() {
const [games, setGames] = useState<MyGameSummary[] | null>(null);

View File

@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { GuestWarning, NicknameForm } from '../components/NicknameForm';
import { GameNotice, gameName } from '../components/GameNotice';
import { QrModal } from '../components/QrModal';
import { api, type RoomPreview } from '../net/api';
@@ -15,6 +15,7 @@ import { sounds } from '../lib/feedback';
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
import { useWide } from '../lib/media';
import { GameHelp } from '../components/GameHelp';
import { PlayerName } from '../components/PlayerCard';
export default function RoomPage() {
const { code = '' } = useParams();
@@ -146,6 +147,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
socket.leave();
nav('/');
};
// Leaving mid-game forfeits, so only that case asks first; otherwise one tap leaves.
const forfeits = room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id);
const askLeave = () => (forfeits ? setLeaveAsk(true) : doLeave());
return (
<>
@@ -154,13 +158,17 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
{g.nameKo} 방 {formatRoomCode(code)}
</h1>
<div className="room-bar-inner">
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
{/* ← 홈 leaves the room, same as [나가기]. */}
<button className="btn btn-ghost btn-sm" onClick={askLeave} aria-label="방 나가고 홈으로">
← 홈
</button>
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
{g.nameKo}
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
{/* On phones the link sits in the menu so ← 홈 · 채팅 · 🚪 나가기 · 메뉴 fit on one line. */}
<span className="wide-only">
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
</span>
<span style={{ flex: 1 }} />
{!roomChatClosed && (
<span className="phone-only">
@@ -170,6 +178,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙
</button>
<button className="btn btn-danger btn-sm" onClick={askLeave}>
🚪 나가기
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
⋯ 메뉴
</button>
@@ -201,6 +212,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
{menu && (
<Sheet onClose={() => setMenu(false)} label="메뉴">
<h2>메뉴</h2>
<span className="phone-only">
<CopyButton text={`${location.origin}/r/${code}`} label={<>🔗 방 <CodeLabel code={code} /> 링크 복사</>} done="링크 복사됨 ✓" className="btn btn-secondary btn-block" />
</span>
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙 보기
</button>
@@ -212,15 +226,12 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
게임 끝내기(무효)
</button>
)}
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
🚪 방 나가기
</button>
</Sheet>
)}
{leaveAsk && (
<ConfirmSheet
title="방에서 나갈까요?"
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
body="게임 중에 나가면 기권으로 처리돼요."
confirm="나가기"
danger
onConfirm={doLeave}
@@ -322,7 +333,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
{s.user.nickname}
<PlayerName user={s.user} gameId={room.gameId} session={room.sessionStats[s.user.id]} />
{s.user.id === me.id && <span className="muted small"> (나)</span>}
</div>
<div className="row small" style={{ gap: 6 }}>
@@ -347,7 +358,17 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
),
)}
</div>
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
{room.spectators.length > 0 && (
<p className="muted small">
관전 {room.spectators.length}명:{' '}
{room.spectators.map((u, i) => (
<span key={u.id}>
{i > 0 && ', '}
<PlayerName user={u} gameId={room.gameId} />
</span>
))}
</p>
)}
{mySeat >= 0 && room.allowSpectators && (
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
관전으로 바꾸기
@@ -370,6 +391,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{me.kind === 'guest' && !practice && <GuestWarning />}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
<GameNotice gameId={room.gameId} />
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
@@ -643,7 +665,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<div key={u} className="row">
<strong style={{ width: 40 }}>{i + 1}등</strong>
{players[u] && <Avatar user={players[u]!} size={28} />}
<span>{players[u]?.nickname ?? '나간 사람'}</span>
<span style={{ minWidth: 0 }}>{players[u] ? <PlayerName user={players[u]!} gameId={room.gameId} session={room.sessionStats[u]} /> : '나간 사람'}</span>
<span className="muted small" style={{ marginLeft: 'auto' }}>
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
</span>

View File

@@ -1,12 +1,15 @@
/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router';
import { formatRoomCode } from '@bg/shared/lite';
import { formatRoomCode, type MyGameSummary } from '@bg/shared/lite';
import { ConfirmSheet, Sheet } from '../../components/ui';
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
import { useSession } from '../../store/session';
import { toast } from '../../store/toast';
import { SchemaForm, type JsonSchema } from './SchemaForm';
import { gameName } from '../../components/GameNotice';
import { RecordCell, record, recordText, resultLabel, sumStats } from '../../components/PlayerCard';
import type { GameStat } from '../../net/api';
const a = {
get: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
@@ -448,6 +451,7 @@ function Users() {
<th>닉네임</th>
<th className="wide-only">로그인</th>
<th>권한</th>
<th className="wide-only">전적</th>
<th className="wide-only">마지막 접속</th>
<th>상태</th>
</tr>
@@ -461,6 +465,7 @@ function Users() {
</td>
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
<td className="wide-only small">{u.played ? `${u.played}판 ${u.wins}승` : '-'}</td>
<td className="wide-only small">{time(u.lastSeenAt)}</td>
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
</tr>
@@ -474,7 +479,7 @@ function Users() {
}
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
const [d, reload] = useLoad<{ user: any; stats: GameStat[] }>(`/users/${id}`);
const [nick, setNick] = useState('');
const [reason, setReason] = useState('');
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
@@ -504,7 +509,8 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
{guestDeletion(d.user) && <> · {guestDeletion(d.user)}</>}
</p>
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
<UserRecord stats={d.stats} />
<UserGames id={id} />
<div className="row">
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
@@ -557,6 +563,120 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
);
}
/** 전체 기록 + 게임별 기록 (연습 게임·무효 판은 빠져요). */
function UserRecord({ stats }: { stats: GameStat[] }) {
const total = record(sumStats(stats));
return (
<section className="stack" style={{ gap: 10 }} aria-label="기록">
<h3>전체 기록</h3>
{total.played === 0 ? (
<p className="muted small">끝까지 한 게임이 없어요.</p>
) : (
<>
<div className="record-grid">
<RecordCell label="판" value={total.played} />
<RecordCell label="승" value={total.wins} />
<RecordCell label="패" value={total.losses} />
<RecordCell label="무" value={total.draws} />
<RecordCell label="승률" value={`${total.rate}%`} />
</div>
<h3>게임별 기록</h3>
<table className="data-table compact">
<thead>
<tr>
<th>게임</th>
<th>판</th>
<th>승</th>
<th>패</th>
<th>무</th>
<th>승률</th>
</tr>
</thead>
<tbody>
{stats.map((s) => {
const r = record(s);
return (
<tr key={s.gameId}>
<td>{gameName(s.gameId)}</td>
<td>{r.played}</td>
<td>{r.wins}</td>
<td>{r.losses}</td>
<td>{r.draws}</td>
<td>{r.rate}%</td>
</tr>
);
})}
</tbody>
</table>
</>
)}
<p className="muted small">컴퓨터와 한 연습 게임과 무효 처리된 판은 기록에 넣지 않아요.</p>
</section>
);
}
/** 최근 게임: 끝난 판, 최신순 (내 게임 기록과 같은 목록). */
function UserGames({ id }: { id: string }) {
const [games, setGames] = useState<MyGameSummary[] | null>(null);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(
(before: string | null) => {
setLoading(true);
a.get<{ games: MyGameSummary[]; next: string | null }>(`/users/${id}/games${before ? `?before=${before}` : ''}`)
.then((r) => {
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
setNext(r.next);
})
.catch((e) => toast((e as Error).message, 'error'))
.finally(() => setLoading(false));
},
[id],
);
useEffect(() => load(null), [load]);
return (
<section className="stack" style={{ gap: 10 }} aria-label="최근 게임">
<h3>최근 게임</h3>
{games === null ? (
<p className="muted small">불러오는 중…</p>
) : games.length === 0 ? (
<p className="muted small">끝난 게임이 없어요.</p>
) : (
<table className="data-table compact">
<thead>
<tr>
<th>날짜</th>
<th>게임</th>
<th>결과</th>
<th className="wide-only">함께 한 사람</th>
</tr>
</thead>
<tbody>
{games.map((g) => (
<tr key={g.gamePk}>
<td className="small">{time(g.startedAt)}</td>
<td>{gameName(g.gameId)}</td>
<td>{resultLabel(g)}</td>
<td className="wide-only small">
{g.players
.filter((p) => !p.me)
.map((p) => p.nickname)
.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
{next && (
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
{loading ? '불러오는 중…' : '더 보기'}
</button>
)}
</section>
);
}
// ------------------------------------------------------------------ admins (superadmin)
function Admins() {

View File

@@ -1206,3 +1206,54 @@ button.header-user {
flex: 1;
border-top: 1px solid var(--border);
}
/* Player name that opens the record card (room) */
.name-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
text-decoration: underline dotted;
text-underline-offset: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.name-btn:hover {
text-decoration-style: solid;
}
.record-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.record-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 4px;
border-radius: var(--radius);
background: var(--surface-2);
}
.record-cell strong {
font-size: 1.3rem;
}
.guest-warning {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius);
background: var(--warning-soft);
color: var(--text);
text-align: left;
}
.data-table.compact th,
.data-table.compact td {
padding: 8px 8px;
}