M9: 게임 기록 다시 보기
- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다 (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각, start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store), RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410). - 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더), 내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기]. - 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts. - 문서: docs/09 §8, docs/07 §10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ const JoinPage = lazy(() => import('./pages/JoinPage'));
|
||||
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
||||
const GamePage = lazy(() => import('./pages/GamePage'));
|
||||
const MePage = lazy(() => import('./pages/MePage'));
|
||||
const ReplayPage = lazy(() => import('./pages/ReplayPage'));
|
||||
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
|
||||
@@ -67,6 +68,7 @@ function Shell() {
|
||||
<Route path="/g/:gameId" element={<GamePage />} />
|
||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/replay/:gamePk" element={<ReplayPage />} />
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
||||
import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -66,6 +66,9 @@ 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'),
|
||||
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)}`),
|
||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** 계정 페이지 (docs/05 §7). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { gameName } from '../components/GameNotice';
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
@@ -152,6 +153,8 @@ export default function MePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MyGames />
|
||||
|
||||
<section className="stack">
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
||||
로그아웃
|
||||
@@ -210,3 +213,83 @@ 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);
|
||||
const [next, setNext] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const load = (before: string | null) => {
|
||||
setLoading(true);
|
||||
api
|
||||
.myGames(before)
|
||||
.then((r) => {
|
||||
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
|
||||
setNext(r.next);
|
||||
})
|
||||
.catch((e) => toast((e as Error).message, 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => load(null), []);
|
||||
|
||||
return (
|
||||
<section className="card stack" aria-labelledby="my-games-title">
|
||||
<h2 id="my-games-title">내 게임 기록</h2>
|
||||
{games === null ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="muted">아직 다시 볼 게임이 없어요.</p>
|
||||
) : (
|
||||
<ul className="my-games">
|
||||
{games.map((g) => {
|
||||
const label = resultLabel(g);
|
||||
return (
|
||||
<li key={g.gamePk} className="my-game">
|
||||
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<strong>{gameName(g.gameId)}</strong>
|
||||
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
|
||||
</div>
|
||||
<span className="muted small">
|
||||
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||||
</span>
|
||||
<span className="small">
|
||||
{g.players.map((p, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ' · '}
|
||||
{p.me ? <strong>나</strong> : p.nickname}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
{g.replayable ? (
|
||||
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
|
||||
▶ 다시 보기
|
||||
</Link>
|
||||
) : (
|
||||
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
|
||||
다시 볼 수 없어요
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{next && (
|
||||
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
|
||||
{loading ? '불러오는 중…' : '더 보기'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
261
apps/web/src/pages/ReplayPage.tsx
Normal file
261
apps/web/src/pages/ReplayPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 게임 기록 다시 보기 (docs/09 §8, docs/07 §10). Shows a finished game step by step with the game's own
|
||||
* Board/Side, read-only: `act` does nothing and clicks/keys inside the board are swallowed.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { applyViewPatch, type ReplayData } from '@bg/shared/lite';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { gameName } from '../components/GameNotice';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { useWide } from '../lib/media';
|
||||
|
||||
const STEP_MS = 1000;
|
||||
|
||||
export default function ReplayPage() {
|
||||
const { gamePk = '' } = useParams();
|
||||
const me = useSession((s) => s.me);
|
||||
const nav = useNavigate();
|
||||
const [data, setData] = useState<{ replay: ReplayData; ui: GameUI } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
let alive = true;
|
||||
setData(null);
|
||||
setError(null);
|
||||
api.replay(gamePk).then(
|
||||
async (replay) => {
|
||||
const ui = await loadGameUI(replay.gameId);
|
||||
if (!alive) return;
|
||||
if (ui) setData({ replay, ui });
|
||||
else setError('이 게임은 다시 볼 수 없어요.');
|
||||
},
|
||||
(e) => alive && setError((e as Error).message),
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [gamePk, me?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Came from the room or the account page: go back there; opened directly: the account page.
|
||||
const back = () => ((window.history.state as { idx?: number } | null)?.idx ? nav(-1) : nav('/me'));
|
||||
|
||||
if (!me || error || !data) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="다시 보기" onBack={back} />
|
||||
<main className="page">
|
||||
{!me || error ? (
|
||||
<section className="card stack">
|
||||
<h1>{error ?? '로그인한 뒤에 다시 볼 수 있어요.'}</h1>
|
||||
<button className="btn btn-primary btn-block" onClick={() => nav('/me')}>
|
||||
내 게임 기록 보기
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
|
||||
홈으로
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<p className="muted">기록을 불러오는 중…</p>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Player data={data.replay} ui={data.ui} onBack={back} />;
|
||||
}
|
||||
|
||||
/** Swallows clicks and pointer/keyboard input inside the board (scrolling still works). */
|
||||
const swallow = (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.type === 'click') e.preventDefault();
|
||||
};
|
||||
const swallowKeys = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') swallow(e);
|
||||
};
|
||||
const readOnly = {
|
||||
className: 'replay-readonly',
|
||||
onClickCapture: swallow,
|
||||
onPointerDownCapture: swallow,
|
||||
onPointerUpCapture: swallow,
|
||||
onPointerMoveCapture: swallow,
|
||||
onMouseDownCapture: swallow,
|
||||
onKeyDownCapture: swallowKeys,
|
||||
onDragStartCapture: swallow,
|
||||
};
|
||||
|
||||
function Player({ data, ui, onBack }: { data: ReplayData; ui: GameUI; onBack: () => void }) {
|
||||
const wide = useWide();
|
||||
const tapConfirm = useSession((s) => s.settings.tapConfirm);
|
||||
const views = useMemo(() => {
|
||||
const out: unknown[] = [];
|
||||
let v: unknown;
|
||||
for (const f of data.frames) {
|
||||
v = f.view !== undefined ? f.view : applyViewPatch(v, f.patch);
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}, [data]);
|
||||
const last = views.length - 1;
|
||||
const [i, setI] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
// The board is remounted on jumps (처음/끝/slider) so its move animations and sounds only follow single steps.
|
||||
const [mount, setMount] = useState(0);
|
||||
const at = useRef(0);
|
||||
|
||||
const go = (n: number, keepPlaying = false) => {
|
||||
const t = Math.max(0, Math.min(last, n));
|
||||
if (Math.abs(t - at.current) > 1) setMount((m) => m + 1);
|
||||
at.current = t;
|
||||
setI(t);
|
||||
if (!keepPlaying) setPlaying(false);
|
||||
};
|
||||
const goRef = useRef(go);
|
||||
goRef.current = go;
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
if (i >= last) return setPlaying(false);
|
||||
const t = setTimeout(() => goRef.current(i + 1, true), STEP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [playing, i, last]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el?.closest('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const d = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
|
||||
if (d) goRef.current(at.current + d);
|
||||
else if (e.key === 'Home') goRef.current(0);
|
||||
else if (e.key === 'End') goRef.current(Number.MAX_SAFE_INTEGER);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const frame = data.frames[i]!;
|
||||
const view = views[i];
|
||||
const nick = (id: string | null) => (id && data.players[id]?.nickname) || '알 수 없음';
|
||||
const who = frame.actor === data.me ? '내가' : `${nick(frame.actor)}님이`;
|
||||
const desc =
|
||||
i === 0
|
||||
? '게임을 시작했어요.'
|
||||
: frame.auto
|
||||
? `시간이 지나거나 자리를 떠서 ${frame.actor === data.me ? '내' : `${nick(frame.actor)}님`} 차례를 자동으로 넘겼어요.`
|
||||
: `${who} 움직였어요.`;
|
||||
// The game's own status line talks to a live player ("놓을 곳을 눌러 주세요"); say whose turn it was instead.
|
||||
const turn = frame.active.length
|
||||
? `이때 ${frame.active.map((p) => (p === data.me ? '내' : `${nick(p)}님`)).join(', ')} 차례였어요.`
|
||||
: ui.statusText(view, data.me, data.players);
|
||||
const sec = Math.max(0, Math.round((frame.at - data.startedAt) / 1000));
|
||||
const clock = `${Math.floor(sec / 60)}분 ${String(sec % 60).padStart(2, '0')}초`;
|
||||
const props = {
|
||||
view,
|
||||
me: data.me,
|
||||
players: data.players,
|
||||
act: () => {},
|
||||
pending: false,
|
||||
deadline: null,
|
||||
active: frame.active,
|
||||
tapConfirm,
|
||||
};
|
||||
const when = new Date(data.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
|
||||
const status = (
|
||||
<div className="card turn-status" role="status" style={{ padding: '14px 16px', fontWeight: 700, fontSize: '1.15rem', width: '100%' }}>
|
||||
{i === last ? `끝: ${data.result.summary}` : turn}
|
||||
</div>
|
||||
);
|
||||
const controls = (
|
||||
<section className="replay-controls" aria-label="다시 보기 조작">
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong className="replay-count">
|
||||
{i === 0 ? '시작' : `${i}번째`} <span className="muted">/ 전체 {last}번</span>
|
||||
</strong>
|
||||
<span className="muted small">{clock}</span>
|
||||
</div>
|
||||
<p className="small replay-desc" aria-live="polite">
|
||||
{desc}
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
className="replay-slider"
|
||||
min={0}
|
||||
max={last}
|
||||
step={1}
|
||||
value={i}
|
||||
onChange={(e) => go(Number(e.target.value))}
|
||||
aria-label="몇 번째 장면인지 고르기"
|
||||
aria-valuetext={i === 0 ? '시작' : `${i}번째 / 전체 ${last}번`}
|
||||
/>
|
||||
<div className="replay-buttons">
|
||||
<button className="btn btn-secondary" onClick={() => go(0)} disabled={i === 0} aria-label="처음으로">
|
||||
<span aria-hidden>⏮</span>처음
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go(i - 1)} disabled={i === 0} aria-label="이전">
|
||||
<span aria-hidden>◁</span>이전
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (playing) return setPlaying(false);
|
||||
if (i >= last) go(0);
|
||||
setPlaying(true);
|
||||
}}
|
||||
disabled={last === 0}
|
||||
aria-label={playing ? '멈춤' : '재생'}
|
||||
>
|
||||
<span aria-hidden>{playing ? '⏸' : '▶'}</span>
|
||||
{playing ? '멈춤' : '재생'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go(i + 1)} disabled={i === last} aria-label="다음">
|
||||
<span aria-hidden>▷</span>다음
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go(last)} disabled={i === last} aria-label="끝으로">
|
||||
<span aria-hidden>⏭</span>끝
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="room-bar" aria-label="다시 보기 메뉴">
|
||||
<h1 className="sr-only">{gameName(data.gameId)} 다시 보기</h1>
|
||||
<div className="room-bar-inner">
|
||||
<button className="btn btn-ghost btn-sm" onClick={onBack}>
|
||||
← 뒤로
|
||||
</button>
|
||||
<strong style={{ fontSize: '1.1rem' }}>{gameName(data.gameId)} 다시 보기</strong>
|
||||
<span className="muted small wide-only">{when}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div className={`game-layout${wide ? '' : ' replay-narrow'}`}>
|
||||
<div className="game-main">
|
||||
{!wide && status}
|
||||
<div {...readOnly}>
|
||||
<ui.Board key={mount} {...props} />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="game-side" aria-label="게임 정보">
|
||||
{wide && controls}
|
||||
{wide && status}
|
||||
<div {...readOnly}>
|
||||
<ui.Side key={mount} {...props} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{!wide && (
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">{controls}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
@@ -614,6 +614,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
|
||||
판 다시 보기
|
||||
</button>
|
||||
{room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && (
|
||||
<Link className="btn btn-secondary btn-block" to={`/replay/${room.lastGamePk}`}>
|
||||
▶ 처음부터 다시 보기
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-block" onClick={onLeave}>
|
||||
방 나가기
|
||||
</button>
|
||||
|
||||
@@ -957,3 +957,80 @@ label.field {
|
||||
.schema-group > legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Replay (docs/09 §8): read-only board, step controls in the side panel (PC) or a bottom bar (phone/tablet) */
|
||||
.replay-readonly {
|
||||
display: contents;
|
||||
}
|
||||
.replay-readonly button,
|
||||
.replay-readonly [role='button'],
|
||||
.replay-readonly svg {
|
||||
cursor: default;
|
||||
}
|
||||
.replay-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px;
|
||||
}
|
||||
.bottom-bar .replay-controls {
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.replay-count {
|
||||
font-size: 1.1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.replay-desc {
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.replay-slider {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
.replay-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.replay-buttons .btn {
|
||||
min-width: 0;
|
||||
min-height: 60px;
|
||||
padding: 0 4px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.replay-buttons .btn span {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.game-layout.replay-narrow {
|
||||
padding-bottom: calc(240px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.my-games {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.my-game {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.my-game:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user