From ce27c711765f340a26b6dbf912f20979ec39268b Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:19:57 +0300 Subject: [PATCH] feat(frontend): lay out the replay page in the new design A summary card with the result, date, duration, move count, players and score; players and the move number beside the board; a controls card with the transport buttons, speed, timeline and the shown move with its tags; the landing pit of the shown move is ringed. --- .../src/components/history/ReplayView.css | 32 +++ .../src/components/history/ReplayView.tsx | 265 ++++++++---------- apps/frontend/src/util/GameResultText.ts | 7 + apps/frontend/src/util/MoveText.ts | 15 - docs/specs/web-redesign/tasks.md | 2 +- 5 files changed, 150 insertions(+), 171 deletions(-) create mode 100644 apps/frontend/src/components/history/ReplayView.css delete mode 100644 apps/frontend/src/util/MoveText.ts diff --git a/apps/frontend/src/components/history/ReplayView.css b/apps/frontend/src/components/history/ReplayView.css new file mode 100644 index 0000000..b8d8ffa --- /dev/null +++ b/apps/frontend/src/components/history/ReplayView.css @@ -0,0 +1,32 @@ +.sum { display: flex; align-items: center; gap: 28px; padding: 22px 26px; border-radius: 28px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.sum-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.sum-info .pill { align-self: flex-start; } +.sum-meta { font-size: 14px; color: var(--muted); } +.sum-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; flex: 1; min-width: 0; } +.sum-vs .pn { font-weight: 700; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sum-vs .pn.r { text-align: right; } +.big { font-weight: 800; font-size: 34px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.ctl { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); } +.ctlrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.tbtns { display: flex; align-items: center; gap: 8px; } +.tb { width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--surface2); color: var(--ink); display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } +.tb.big { width: 54px; height: 54px; background: var(--accent); color: var(--onAccent); } +.tb:disabled { opacity: .35; cursor: default; } +.speed { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); } +.segs { display: flex; gap: 2px; padding: 3px; border-radius: 24px; background: var(--surface2); } +.seg { min-width: 46px; height: 38px; border: none; border-radius: 19px; background: transparent; color: var(--muted); font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; } +.seg.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); } +.range { width: 100%; height: 28px; margin: 0; accent-color: var(--accent); cursor: pointer; } +.desc { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; color: var(--muted); } +.desc b { color: var(--ink); } +.tb:focus-visible, .seg:focus-visible, .range:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +@media (max-width: 640px) { + .sum { flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; border-radius: 24px; } + .sum .btn { width: 100%; } + .big { font-size: 28px; } + .sum-vs .pn { font-size: 15px; } + .ctlrow { justify-content: center; } + .tbtns { width: 100%; justify-content: space-between; } +} diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 0885f72..61e0973 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -7,28 +7,31 @@ import { DEFAULT_ANIMATION_INTERVAL, GameRecord, getBoardAfterMove, + describeMove, getClockAtMove, - getColorByBrightness, getPlayerDisplayName, PitAnimator, PlayerSummary, Theme, } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { v4 } from "uuid"; import { Context } from "../../context/context"; import useMoveKeys from "../../hooks/useMoveKeys"; import { createBoardViewModel } from "../../util/BoardViewModels"; -import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText"; -import { getMoveText } from "../../util/MoveText"; +import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; import BoardView from "../board/BoardView"; -import Button from "../Button"; import PageContainer from "../PageContainer"; import AppHeader from "../headerbar/AppHeader"; -import { PlusIcon } from "../Icons"; +import { FirstIcon, LastIcon, NextIcon, PauseIcon, PlayIcon, PlusIcon, PreviousIcon, ReplayIcon } from "../Icons"; +import PlayerCard from "../game/PlayerCard"; import GameClockView from "../GameClockView"; import MoveList from "./MoveList"; +import MoveTags from "./MoveTags"; +import { GAME_STEP_GAME_MOVE } from "mancala.js"; +import "../../styles/game.css"; +import "./ReplayView.css"; const SPEEDS = [1, 2, 4]; // Extra pause after a move's animation while playing, at 1x. @@ -137,29 +140,42 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga togglePlay, }); - const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); - const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); - const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor); const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); const playerName = (playerId: string) => name(playerId === record.player1.userId ? record.player1 : record.player2); const stones = getBoardAfterMove(moves, moveNumber); const [topPlayer, topStore, bottomPlayer, bottomStore] = revert - ? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]] - : [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]]; + ? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE] + : [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE]; const atEnd = moveNumber >= total; + const shownMove = moveNumber > 0 ? moves[moveNumber - 1] : undefined; + const nextMove = moves[moveNumber]; + // Where the shown move's last stone landed; not while a move is being animated. + const isAnimating = boardViewModel.pits.some((pit) => pit.highlight); + const sownSteps = shownMove?.gameSteps.filter((step) => step.type === GAME_STEP_GAME_MOVE) ?? []; + const landIndex = !isAnimating && sownSteps.length > 0 + ? sownSteps[sownSteps.length - 1].index % stones.length : undefined; // Each player's clock after the shown move; games saved before the game clock have none. const clockTimes = getClockAtMove(record, moveNumber); const renderClock = (player: PlayerSummary) => clockTimes && ( ); + const playerCard = (player: PlayerSummary, storeIndex: number, alignRight: boolean) => ( + + ); - const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => ( - + const controlButton = (label: string, icon: React.ReactNode, onClick: () => void, disabled = false) => ( + ); return ( @@ -170,146 +186,85 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga -
-
-
{`${getResultText(record, viewerId, t)} · ${getScoreText(record, viewerId)}`}
-
{`${formatDate(record.endedAt, i18n.language)} · ${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`}
+
+
+
+ {getResultText(record, viewerId, t)} +
+ {[formatDate(record.endedAt, i18n.language), + `${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`, + t("MoveCount", { count: total })].join(" · ")} +
+
+
+ {name(topPlayer)} + {`${record.board[topStore]} – ${record.board[bottomStore]}`} + {name(bottomPlayer)} +
+ {total > 0 && ( + + )} +
+ +
+
{playerCard(topPlayer, topStore, false)}
+
{t("MoveOf", { number: moveNumber, total })}
+
{playerCard(bottomPlayer, bottomStore, true)}
+
+ +
+
+
+
+
+ {controlButton(t("FirstMove"), , () => jumpTo(0), moveNumber === 0)} + {controlButton(t("PreviousMove"), , () => jumpTo(moveNumber - 1), moveNumber === 0)} + + {controlButton(t("NextMove"), , () => { setPlaying(false); animateNextMove(); }, atEnd)} + {controlButton(t("LastMove"), , () => jumpTo(total), atEnd)} +
+
+ {t("Speed")} +
+ {SPEEDS.map((s) => ( + + ))} +
+
+
+ jumpTo(Number(event.target.value))} /> +
+ {moveNumber === 0 ? t("Start") : t("MoveNumber", { number: moveNumber })} + {shownMove + ? `${playerName(shownMove.playerId)} · ${t("PitNumber", { pit: describeMove(moves, moveNumber).pit })}` + : t("StartPosition")} + {shownMove && } +
+
+ + +
- {atEnd && !playing && total > 0 && ( -
- -
{`${name(topPlayer)} · ${topStore}`}{renderClock(topPlayer)}
- -
{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}
- -
-
- {controlButton("⏮", t("Start"), () => jumpTo(0), moveNumber === 0)} - {controlButton("◀", "←", () => jumpTo(moveNumber - 1), moveNumber === 0)} - {controlButton(playing ? "⏸" : "▶", playing ? "Pause" : "Play", togglePlay, total === 0)} - {controlButton("▶|", "→", () => { setPlaying(false); animateNextMove(); }, atEnd)} - {controlButton("⏭", t("GameOver"), () => jumpTo(total), atEnd)} - - {`${t("Speed")}:`} - {SPEEDS.map((s) => ( - - ))} - -
-
- {t("MoveOf", { number: moveNumber, total })} - - {moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`} - -
- jumpTo(Number(event.target.value))} /> -
- - - - + ); }; diff --git a/apps/frontend/src/util/GameResultText.ts b/apps/frontend/src/util/GameResultText.ts index a1d1607..c843327 100644 --- a/apps/frontend/src/util/GameResultText.ts +++ b/apps/frontend/src/util/GameResultText.ts @@ -20,6 +20,13 @@ export function getResultText(game: FinishedGameSummary, viewerId: string | unde return outcome; } +// Class of the result's pill: "win" when someone won (and the viewer did not lose), "lose" when the viewer lost. +export function getResultPillClass(game: FinishedGameSummary, viewerId: string | undefined): string { + if (game.result === null || game.result === "draw") return ""; + const viewerSeat = getViewerSeat(game, viewerId); + return viewerSeat && viewerSeat !== game.result ? "lose" : "win"; +} + // Final stones in the stores, the viewer's first when they played. export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string { const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE]; diff --git a/apps/frontend/src/util/MoveText.ts b/apps/frontend/src/util/MoveText.ts deleted file mode 100644 index 7c97ec8..0000000 --- a/apps/frontend/src/util/MoveText.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describeMove, PlayedMove } from "@mancala/core"; - -type Translate = (key: string, options?: Record) => string; - -// "Pit 3 · ★ Extra turn · +1 to store" -export function getMoveText(moves: PlayedMove[], moveNumber: number, t: Translate): string { - const move = describeMove(moves, moveNumber); - const events = [ - move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined, - move.capture ? `✂ ${t("Capture")}` : undefined, - move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined, - move.gameOver ? `⚑ ${t("GameOver")}` : undefined, - ].filter(Boolean); - return [t("PitNumber", { pit: move.pit }), ...events].join(" · "); -} diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 32734f4..7e39c7f 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -9,7 +9,7 @@ Work through the tasks in order; each ends with its checks passing and a commit. action) on every page; restyle the lobby, privacy page, loading and connection panels. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. - [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. -- [ ] 6. **Web app replay page**: summary card, controls card, move list. +- [x] 6. **Web app replay page**: summary card, controls card, move list. - [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; `AGENTS.md` and `TODO.md`.