Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
32
apps/frontend/src/components/history/ReplayView.css
Normal file
32
apps/frontend/src/components/history/ReplayView.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
@ -7,28 +7,31 @@ import {
|
|||||||
DEFAULT_ANIMATION_INTERVAL,
|
DEFAULT_ANIMATION_INTERVAL,
|
||||||
GameRecord,
|
GameRecord,
|
||||||
getBoardAfterMove,
|
getBoardAfterMove,
|
||||||
|
describeMove,
|
||||||
getClockAtMove,
|
getClockAtMove,
|
||||||
getColorByBrightness,
|
|
||||||
getPlayerDisplayName,
|
getPlayerDisplayName,
|
||||||
PitAnimator,
|
PitAnimator,
|
||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
Theme,
|
Theme,
|
||||||
} from "@mancala/core";
|
} from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
import useMoveKeys from "../../hooks/useMoveKeys";
|
import useMoveKeys from "../../hooks/useMoveKeys";
|
||||||
import { createBoardViewModel } from "../../util/BoardViewModels";
|
import { createBoardViewModel } from "../../util/BoardViewModels";
|
||||||
import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
|
import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText";
|
||||||
import { getMoveText } from "../../util/MoveText";
|
|
||||||
import BoardView from "../board/BoardView";
|
import BoardView from "../board/BoardView";
|
||||||
import Button from "../Button";
|
|
||||||
import PageContainer from "../PageContainer";
|
import PageContainer from "../PageContainer";
|
||||||
import AppHeader from "../headerbar/AppHeader";
|
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 GameClockView from "../GameClockView";
|
||||||
import MoveList from "./MoveList";
|
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];
|
const SPEEDS = [1, 2, 4];
|
||||||
// Extra pause after a move's animation while playing, at 1x.
|
// 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,
|
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 name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
|
||||||
const playerName = (playerId: string) =>
|
const playerName = (playerId: string) =>
|
||||||
name(playerId === record.player1.userId ? record.player1 : record.player2);
|
name(playerId === record.player1.userId ? record.player1 : record.player2);
|
||||||
const stones = getBoardAfterMove(moves, moveNumber);
|
const stones = getBoardAfterMove(moves, moveNumber);
|
||||||
const [topPlayer, topStore, bottomPlayer, bottomStore] = revert
|
const [topPlayer, topStore, bottomPlayer, bottomStore] = revert
|
||||||
? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]]
|
? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE]
|
||||||
: [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]];
|
: [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE];
|
||||||
const atEnd = moveNumber >= total;
|
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.
|
// Each player's clock after the shown move; games saved before the game clock have none.
|
||||||
const clockTimes = getClockAtMove(record, moveNumber);
|
const clockTimes = getClockAtMove(record, moveNumber);
|
||||||
const renderClock = (player: PlayerSummary) => clockTimes && (
|
const renderClock = (player: PlayerSummary) => clockTimes && (
|
||||||
<GameClockView running={false}
|
<GameClockView running={false}
|
||||||
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
||||||
);
|
);
|
||||||
|
const playerCard = (player: PlayerSummary, storeIndex: number, alignRight: boolean) => (
|
||||||
|
<PlayerCard
|
||||||
|
name={name(player)}
|
||||||
|
isGuest={player.isGuest}
|
||||||
|
details={[
|
||||||
|
viewerId ? t(player.userId === viewerId ? "You" : "Opponent") : "",
|
||||||
|
t("StoreCount", { count: stones[storeIndex] }),
|
||||||
|
].filter(Boolean).join(" · ")}
|
||||||
|
active={nextMove?.playerId === player.userId}
|
||||||
|
clock={renderClock(player)}
|
||||||
|
alignRight={alignRight} />
|
||||||
|
);
|
||||||
|
|
||||||
const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => (
|
const controlButton = (label: string, icon: React.ReactNode, onClick: () => void, disabled = false) => (
|
||||||
<button className="control" title={title} aria-label={title} disabled={disabled} onClick={onClick}
|
<button className="tb" aria-label={label} disabled={disabled} onClick={onClick}>{icon}</button>
|
||||||
style={{ background: theme.pitColor, color: getColorByBrightness(theme.pitColor, theme.textColor, theme.textLightColor) }}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -170,18 +186,33 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
</button>
|
</button>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<div className="result" style={{ background: theme.boardColor, color: panelTextColor }}>
|
<main className="wrap">
|
||||||
<div>
|
<section className="sum" aria-label={t("GameSummary")}>
|
||||||
<div className="result-title">{`${getResultText(record, viewerId, t)} · ${getScoreText(record, viewerId)}`}</div>
|
<div className="sum-info">
|
||||||
<div>{`${formatDate(record.endedAt, i18n.language)} · ${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`}</div>
|
<span className={"pill " + getResultPillClass(record, viewerId)}>{getResultText(record, viewerId, t)}</span>
|
||||||
|
<div className="sum-meta">
|
||||||
|
{[formatDate(record.endedAt, i18n.language),
|
||||||
|
`${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`,
|
||||||
|
t("MoveCount", { count: total })].join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
{atEnd && !playing && total > 0 && (
|
</div>
|
||||||
<Button context={context} color={theme.pitColor} text={`▶ ${t("WatchFromStart")}`}
|
<div className="sum-vs">
|
||||||
onClick={() => { showMove(0); setPlaying(true); }} />
|
<span className="pn">{name(topPlayer)}</span>
|
||||||
|
<span className="big">{`${record.board[topStore]} – ${record.board[bottomStore]}`}</span>
|
||||||
|
<span className="pn r">{name(bottomPlayer)}</span>
|
||||||
|
</div>
|
||||||
|
{total > 0 && (
|
||||||
|
<button className="btn pri" onClick={() => { showMove(0); setPlaying(true); }}>
|
||||||
|
<ReplayIcon />{t("WatchFromStart")}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="player" style={{ color: textColor }}>{`${name(topPlayer)} · ${topStore}`}{renderClock(topPlayer)}</div>
|
<div className="gl">
|
||||||
|
<div className="a-opp">{playerCard(topPlayer, topStore, false)}</div>
|
||||||
|
<div className="a-turn"><div className="turn" role="status">{t("MoveOf", { number: moveNumber, total })}</div></div>
|
||||||
|
<div className="a-me">{playerCard(bottomPlayer, bottomStore, true)}</div>
|
||||||
|
<div className="a-board">
|
||||||
<BoardView
|
<BoardView
|
||||||
game={createGameAtMove(baseGame, moves, moveNumber)}
|
game={createGameAtMove(baseGame, moves, moveNumber)}
|
||||||
context={context}
|
context={context}
|
||||||
@ -189,127 +220,51 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
revert={revert}
|
revert={revert}
|
||||||
topName={name(topPlayer)}
|
topName={name(topPlayer)}
|
||||||
bottomName={name(bottomPlayer)}
|
bottomName={name(bottomPlayer)}
|
||||||
lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
|
lastMoveIndex={shownMove?.moveIndex}
|
||||||
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
|
landIndex={landIndex} />
|
||||||
|
</div>
|
||||||
<div className="controls" style={{ color: textColor }}>
|
<div className="a-moves">
|
||||||
<div className="buttons">
|
<section className="ctl" aria-label={t("PlaybackControls")}>
|
||||||
{controlButton("⏮", t("Start"), () => jumpTo(0), moveNumber === 0)}
|
<div className="ctlrow">
|
||||||
{controlButton("◀", "←", () => jumpTo(moveNumber - 1), moveNumber === 0)}
|
<div className="tbtns">
|
||||||
{controlButton(playing ? "⏸" : "▶", playing ? "Pause" : "Play", togglePlay, total === 0)}
|
{controlButton(t("FirstMove"), <FirstIcon />, () => jumpTo(0), moveNumber === 0)}
|
||||||
{controlButton("▶|", "→", () => { setPlaying(false); animateNextMove(); }, atEnd)}
|
{controlButton(t("PreviousMove"), <PreviousIcon />, () => jumpTo(moveNumber - 1), moveNumber === 0)}
|
||||||
{controlButton("⏭", t("GameOver"), () => jumpTo(total), atEnd)}
|
<button className="tb big" aria-label={playing ? t("Pause") : t("Play")} disabled={total === 0} onClick={togglePlay}>
|
||||||
<span className="speed">
|
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||||
{`${t("Speed")}:`}
|
</button>
|
||||||
|
{controlButton(t("NextMove"), <NextIcon />, () => { setPlaying(false); animateNextMove(); }, atEnd)}
|
||||||
|
{controlButton(t("LastMove"), <LastIcon />, () => jumpTo(total), atEnd)}
|
||||||
|
</div>
|
||||||
|
<div className="speed">
|
||||||
|
<span>{t("Speed")}</span>
|
||||||
|
<div className="segs" role="group" aria-label={t("PlaybackSpeed")}>
|
||||||
{SPEEDS.map((s) => (
|
{SPEEDS.map((s) => (
|
||||||
<button key={s} className={"speed-option" + (s === speed ? " active" : "")}
|
<button key={s} className={"seg" + (s === speed ? " on" : "")} aria-pressed={s === speed}
|
||||||
style={{ color: textColor }} onClick={() => setSpeed(s)}>{`${s}x`}</button>
|
onClick={() => setSpeed(s)}>{`${s}x`}</button>
|
||||||
))}
|
))}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="position">
|
|
||||||
<span>{t("MoveOf", { number: moveNumber, total })}</span>
|
|
||||||
<span className="move-text">
|
|
||||||
{moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input className="timeline" type="range" min={0} max={total} value={moveNumber}
|
|
||||||
style={{ accentColor: theme.boardColor }}
|
|
||||||
aria-label={t("Moves")} onChange={(event) => jumpTo(Number(event.target.value))} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<input className="range" type="range" min={0} max={total} value={moveNumber}
|
||||||
|
aria-label={t("MovePosition")} onChange={(event) => jumpTo(Number(event.target.value))} />
|
||||||
|
<div className="desc">
|
||||||
|
<b>{moveNumber === 0 ? t("Start") : t("MoveNumber", { number: moveNumber })}</b>
|
||||||
|
<span>{shownMove
|
||||||
|
? `${playerName(shownMove.playerId)} · ${t("PitNumber", { pit: describeMove(moves, moveNumber).pit })}`
|
||||||
|
: t("StartPosition")}</span>
|
||||||
|
{shownMove && <MoveTags moves={moves} moveNumber={moveNumber} />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<MoveList
|
<MoveList
|
||||||
moves={moves}
|
moves={moves}
|
||||||
selectedMove={moveNumber}
|
selectedMove={moveNumber}
|
||||||
onSelect={jumpTo}
|
onSelect={jumpTo}
|
||||||
playerName={playerName} />
|
playerName={playerName}
|
||||||
|
accentPlayerId={viewerId ?? bottomPlayer.userId} />
|
||||||
<style jsx>{`
|
</div>
|
||||||
.result {
|
</div>
|
||||||
flex-shrink: 0;
|
</main>
|
||||||
width: calc(100% - 32px);
|
|
||||||
max-width: 868px;
|
|
||||||
margin: 16px 16px 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
.result-title {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.player {
|
|
||||||
margin: 6px 0;
|
|
||||||
font-weight: bold;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 8px 16px 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.position {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.move-text {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.timeline {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
/* Built in helper functions and callbacks, which styled-jsx does not scope. */
|
|
||||||
.controls :global(.control) {
|
|
||||||
min-width: 44px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 30px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
.controls :global(.control:disabled) {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.speed {
|
|
||||||
margin-left: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.controls :global(.speed-option) {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 4px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.controls :global(.speed-option.active) {
|
|
||||||
opacity: 1;
|
|
||||||
font-weight: bold;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,6 +20,13 @@ export function getResultText(game: FinishedGameSummary, viewerId: string | unde
|
|||||||
return outcome;
|
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.
|
// Final stones in the stores, the viewer's first when they played.
|
||||||
export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string {
|
export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string {
|
||||||
const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE];
|
const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE];
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { describeMove, PlayedMove } from "@mancala/core";
|
|
||||||
|
|
||||||
type Translate = (key: string, options?: Record<string, unknown>) => 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(" · ");
|
|
||||||
}
|
|
||||||
@ -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.
|
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] 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.
|
- [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.
|
- [ ] 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;
|
- [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes;
|
||||||
`AGENTS.md` and `TODO.md`.
|
`AGENTS.md` and `TODO.md`.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user