Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
10 changed files with 387 additions and 220 deletions
Showing only changes of commit e3e7916265 - Show all commits

View File

@ -0,0 +1,187 @@
/* The board. Sized by its container: pits are a ninth of the width, up to 124 px. Below 600 px the board stands
upright: the top player's store at the top, the viewer's at the bottom, the viewer's pits in the left column. */
.bwrap { container-type: inline-size; }
.board {
--pit: min(124px, calc(100cqw / 9.7));
--pw: var(--pit);
--ph: var(--pit);
--stone: calc(var(--ph) * .21);
display: grid;
grid-template-columns: calc(var(--pit) * 1.1) repeat(6, var(--pit)) calc(var(--pit) * 1.1);
grid-template-rows: var(--pit) var(--pit);
column-gap: calc(var(--pit) * .13);
row-gap: calc(var(--pit) * .26);
width: max-content;
margin: 0 auto;
padding: calc(var(--pit) * .22);
border-radius: calc(var(--pit) * .34);
background: var(--boardBg);
box-shadow: var(--boardShadow);
transition: background .3s;
}
.pit {
position: relative;
grid-row: var(--hr);
grid-column: var(--hc);
width: var(--pw);
height: var(--ph);
border-radius: 50%;
border: none;
padding: 0;
margin: 0;
background: var(--pit);
box-shadow: var(--pitShadow);
font: inherit;
color: inherit;
cursor: default;
transition: transform .18s ease, box-shadow .18s ease;
}
.pit.mine { cursor: pointer; }
@media (hover: hover) {
.pit.mine:hover { transform: translateY(-2px); box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); }
}
.pit:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
.pit.pv, .store.pv, .pit.sel { box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); }
.pit.land, .store.land, .pit.drop, .store.drop { box-shadow: var(--pitShadow), 0 0 0 3px var(--accent), 0 0 22px var(--accentGlow); }
.pit.cap, .store.cap { box-shadow: var(--pitShadow), 0 0 0 3px var(--live), 0 0 22px var(--live); }
.pit.last::after {
content: '';
position: absolute;
inset: -7px;
border-radius: 50%;
border: 2px dashed var(--lastRing);
pointer-events: none;
}
.store {
position: relative;
grid-row: 1 / 3;
border-radius: 999px;
background: var(--pit);
box-shadow: var(--pitShadow);
transition: box-shadow .18s;
}
.store.l { grid-column: 1; }
.store.r { grid-column: 8; }
.st {
position: absolute;
width: var(--stone);
height: var(--stone);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: var(--stoneShadow), inset 0 -2px 4px rgba(0, 0, 0, .25);
transition: left .3s ease, top .3s ease;
pointer-events: none;
}
.st::after {
content: '';
position: absolute;
left: 22%;
top: 15%;
width: 32%;
height: 20%;
border-radius: 50%;
background: rgba(255, 255, 255, .6);
}
.badge {
position: absolute;
left: 50%;
transform: translateX(-50%);
min-width: 30px;
height: 22px;
padding: 0 8px;
border-radius: 11px;
background: var(--chip);
color: var(--chipInk);
font-weight: 700;
font-size: 13px;
line-height: 22px;
box-sizing: border-box;
z-index: 2;
box-shadow: 0 0 0 2px var(--rim);
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.badge.t { top: -11px; }
.badge.b { bottom: -11px; }
.sbadge {
position: absolute;
left: 50%;
bottom: -14px;
transform: translateX(-50%);
min-width: 44px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: var(--chip);
color: var(--chipInk);
font-weight: 800;
font-size: 16px;
line-height: 28px;
box-sizing: border-box;
box-shadow: 0 0 0 2px var(--rim);
text-align: center;
z-index: 2;
font-variant-numeric: tabular-nums;
}
.store.r .sbadge { background: var(--accent); color: var(--onAccent); }
.pvchip {
position: absolute;
right: -6px;
top: -6px;
height: 22px;
min-width: 26px;
padding: 0 6px;
box-sizing: border-box;
border-radius: 11px;
background: var(--accent);
color: var(--onAccent);
font-weight: 800;
font-size: 12px;
line-height: 22px;
z-index: 3;
text-align: center;
pointer-events: none;
}
@container (max-width: 600px) {
.board {
--pw: min(170px, calc((100cqw - 70px) / 2));
--ph: clamp(52px, 15cqw, 66px);
--stone: calc(var(--ph) * .24);
grid-template-columns: var(--pw) var(--pw);
grid-template-rows: calc(var(--ph) * 1.05) repeat(6, var(--ph)) calc(var(--ph) * 1.05);
column-gap: 22px;
row-gap: 10px;
padding: 24px 22px;
border-radius: 32px;
}
.pit { grid-row: var(--vr); grid-column: var(--vc); }
.store.l, .store.r { grid-column: 1 / 3; }
.store.l { grid-row: 1; }
.store.r { grid-row: 8; }
.badge.t { top: 50%; left: auto; right: -13px; transform: translateY(-50%); }
.badge.b { top: 50%; bottom: auto; left: -13px; transform: translateY(-50%); }
.store.l .sbadge { bottom: auto; top: -14px; }
}

View File

@ -1,81 +1,88 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent, useState } from "react";
import { Context } from "../../context/context"; import { useTranslation } from "react-i18next";
import { BoardViewModel, PitViewModel } from "@mancala/core"; import { BoardViewModel, Game, getSowingPath } from "@mancala/core";
import PitView from "./PitView";
import StoreView from "./StoreView";
import { Game } from "@mancala/core";
import { Pit } from "mancala.js"; import { Pit } from "mancala.js";
import { Context } from "../../context/context";
import { getWebTheme } from "../../theme/themes";
import PitView, { PitMarks } from "./PitView";
import StoreView from "./StoreView";
import "./BoardView.css";
// The board as the bottom player sees it: their pits along the bottom from left to right, their store on the right.
// It fills its container's width.
const BoardView: FunctionComponent<{ const BoardView: FunctionComponent<{
game: Game; game: Game;
context: Context; context: Context;
boardId: string;
boardViewModel: BoardViewModel; boardViewModel: BoardViewModel;
// Show player 2 at the bottom.
revert: boolean; revert: boolean;
onPitSelect: (index: number, pit: Pit) => void; topName: string;
// Width in px, e.g. for previews; by default the board is as wide as the viewport. bottomName: string;
width?: number; // Makes the bottom player's pits buttons.
}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect, width }) => { onPitSelect?: (index: number, pit: Pit) => void;
const mancalaGame = game?.mancalaGame; // The bottom player can move now: their pits with stones are highlighted and preview their move.
const theme = context.themeManager.theme; canPlay?: boolean;
// Board index of the pit the last move was played from.
lastMoveIndex?: number;
// Board index where the shown move's last stone landed.
landIndex?: number;
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => {
const { t } = useTranslation();
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
const board = game.mancalaGame.board;
const themeId = getWebTheme(context.themeManager.theme).id;
const stones = boardViewModel.pits.map((pit) => pit.stoneCount);
// The board is 92 units wide (8 columns of 11 plus padding); all board parts are sized in units. // Pits the hovered move drops stones into, with how many each, and where its last stone lands.
const unit = width !== undefined ? width / 92 : (typeof window !== "undefined" ? window.innerWidth / 100 : 10); const preview: Record<number, number> = {};
const pitWidth = unit * 11; const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : [];
const sizeVariables = { path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1));
"--board-unit": width !== undefined ? `${unit}px` : "1vw", const previewLand = path.length > 0 ? path[path.length - 1] : undefined;
"--board-spacing": width !== undefined ? `${unit * 0.3}px` : "5px",
} as React.CSSProperties;
const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => ( const marksOf = (index: number): PitMarks => ({
<PitView highlight: boardViewModel.pits[index]?.highlight,
key={key} preview: preview[index],
pitViewModel={pitViewModel} land: previewLand !== undefined ? index === previewLand : index === landIndex,
context={context} last: index === lastMoveIndex,
onClick={() => onPitSelect(pit.index, pit)} });
fontSize={pitWidth / 6}
/>
);
const createPitViewList = (pits: Pit[]) => const topPits = revert ? board.player1Pits : board.player2Pits;
pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index])); const bottomPits = revert ? board.player2Pits : board.player1Pits;
const [topStore, bottomStore] = revert
? [board.player1BankIndex(), board.player2BankIndex()]
: [board.player2BankIndex(), board.player1BankIndex()];
const player1Pits = createPitViewList(mancalaGame?.board.player1Pits); const pitView = (pit: Pit, position: number, top: boolean) => {
const player2Pits = createPitViewList(mancalaGame?.board.player2Pits); const stoneCount = stones[pit.index];
const player1BankIndex = mancalaGame?.board.player1BankIndex(); const playable = !top && !!canPlay && stoneCount > 0;
const player2BankIndex = mancalaGame?.board.player2BankIndex(); const pitNumber = (top ? 5 - position : position) + 1;
const player1BankViewModel = boardViewModel.pits[player1BankIndex]; return (
const player2BankViewModel = boardViewModel.pits[player2BankIndex]; <PitView
key={pit.index}
index={pit.index}
stoneCount={stoneCount}
top={top}
position={position}
themeId={themeId}
label={t("PitLabel", { name: top ? topName : bottomName, pit: pitNumber, count: stoneCount })}
marks={marksOf(pit.index)}
playable={playable}
onClick={!top && onPitSelect ? () => onPitSelect(pit.index, board.pits[pit.index]) : undefined}
onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} />
);
};
return ( return (
<div className="board" style={{ background: theme.boardColor, ...sizeVariables }}> <div className="bwrap">
<StoreView <div className="board">
context={context} <StoreView index={topStore} stoneCount={stones[topStore]} side="l" themeId={themeId}
pitViewModel={revert ? player2BankViewModel : player1BankViewModel} label={t("StoreLabel", { name: topName, count: stones[topStore] })} marks={marksOf(topStore)} />
gridColumn="8 / 9" {[...topPits].reverse().map((pit, position) => pitView(pit, position, true))}
gridRow="1 / 3" {bottomPits.map((pit, position) => pitView(pit, position, false))}
fontSize={pitWidth / 6} <StoreView index={bottomStore} stoneCount={stones[bottomStore]} side="r" themeId={themeId}
/> label={t("StoreLabel", { name: bottomName, count: stones[bottomStore] })} marks={marksOf(bottomStore)} />
<StoreView </div>
context={context}
pitViewModel={revert ? player1BankViewModel : player2BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
fontSize={pitWidth / 6}
/>
{revert ? player1Pits?.reverse() : player2Pits?.reverse()}
{revert ? player2Pits : player1Pits}
<style jsx>{`
.board {
padding: calc(2 * var(--board-unit));
display: grid;
grid-template-columns: repeat(8, calc(11 * var(--board-unit)));
grid-template-rows: repeat(2, calc(11 * var(--board-unit)));
border-radius: calc(3 * var(--board-unit));
transition: background-color 0.5s;
}
`}</style>
</div> </div>
); );
}; };

View File

@ -1,83 +1,71 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { getColorByBrightness, PitViewModel } from "@mancala/core"; import { PitHighlight } from "@mancala/core";
import { Context } from "../../context/context"; import Stones from "./Stones";
import Util from "../../util/Util";
import StoneView from "./StoneView";
interface PitViewProps { export type PitMarks = {
pitViewModel: PitViewModel; highlight?: PitHighlight;
context: Context; // Stones a hovered move would drop here.
onClick: () => void; preview?: number;
fontSize: number; // The hovered move's last stone lands here, or the shown move's did.
land?: boolean;
// The last move was played from here.
last?: boolean;
};
export function getMarkClasses(marks: PitMarks): string {
const highlight = marks.highlight === "capture" ? " cap" : marks.highlight === "selected" ? " sel"
: marks.highlight ? " drop" : "";
return highlight + (marks.preview ? " pv" : "") + (marks.land ? " land" : "") + (marks.last ? " last" : "");
} }
const PitView: FunctionComponent<PitViewProps> = ({ pitViewModel, context, onClick, fontSize }) => { // A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons.
const textColor = getColorByBrightness( const PitView: FunctionComponent<{
pitViewModel.pitColor, index: number;
context.themeManager.theme.textColor, stoneCount: number;
context.themeManager.theme.textLightColor top: boolean;
position: number;
themeId: string;
label: string;
marks: PitMarks;
// The viewer can play this pit now.
playable?: boolean;
// Set for pits the viewer can click.
onClick?: () => void;
// Called with true when a playable pit is hovered or focused, with false when that ends.
onPreview?: (active: boolean) => void;
}> = ({ index, stoneCount, top, position, themeId, label, marks, playable, onClick, onPreview }) => {
const className = "pit" + (playable ? " mine" : "") + getMarkClasses(marks);
const style = {
"--hr": top ? "1" : "2",
"--hc": String(position + 2),
"--vr": String(position + 2),
"--vc": top ? "2" : "1",
} as React.CSSProperties;
const content = (
<>
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
</>
); );
if (!onClick) {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( return <div className={className} style={style} role="img" aria-label={label}>{content}</div>;
<StoneView key={index} color={pitViewModel.stoneColor} /> }
));
return ( return (
<div <button
className="pit" className={className}
style={style}
aria-label={label}
aria-disabled={!playable}
onClick={onClick} onClick={onClick}
style={{ onMouseEnter={() => playable && onPreview?.(true)}
background: pitViewModel.pitColor, onMouseLeave={() => onPreview?.(false)}
position: "relative", onFocus={() => playable && onPreview?.(true)}
}} onBlur={() => onPreview?.(false)}>
> {content}
{stones} </button>
{pitViewModel.stoneCount > 0 && (
<span
className="stone-count"
style={{
color: textColor,
position: "absolute",
bottom: fontSize / 2,
left: "50%",
transform: "translateX(-50%)",
fontFamily: "monospace",
fontWeight: "bold",
fontSize: fontSize,
pointerEvents: "none",
}}
>
{pitViewModel.stoneCount}
</span>
)}
<style jsx>{`
.pit {
margin: var(--board-spacing);
padding: var(--board-spacing);
border-radius: calc(10 * var(--board-unit));
transition: background-color 0.5s;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
flex-wrap: wrap;
position: relative;
}
.stone-count {
position: absolute;
bottom: calc(1.0 * var(--board-unit));
left: 50%;
transform: translateX(-50%);
font-family: monospace;
font-size: calc(1.5 * var(--board-unit));
font-weight: bold;
pointer-events: none;
}
`}</style>
</div>
); );
}; };
export default PitView; export default PitView;

View File

@ -1,20 +0,0 @@
import * as React from "react";
import { FunctionComponent } from "react";
const StoneView: FunctionComponent<{ color: string }> = ({ color }) => {
return (
<div className="stone" style={{ background: color }}>
<style jsx>{`
.stone {
margin: 1px;
width: calc(1 * var(--board-unit));
height: calc(1 * var(--board-unit));
border-radius: calc(10 * var(--board-unit));
transition: background-color 0.5s;
}
`}</style>
</div>
);
};
export default StoneView;

View File

@ -0,0 +1,37 @@
import * as React from "react";
import { FunctionComponent } from "react";
// Stone colours per theme: highlight, body and shadow of a radial gradient.
const PALETTES: Record<string, string[][]> = {
ceviz: [["#fff1c9", "#e39a32", "#7d4610"], ["#d4fff5", "#2fa597", "#0c4a42"], ["#fffaf0", "#e2d3b6", "#94815f"], ["#ffd0c7", "#c94a3b", "#621a12"], ["#e6ecff", "#6b84c9", "#263a73"]],
gece: [["#ffffff", "#dfe3ee", "#8a92ad"], ["#f3f6ff", "#8fa8ff", "#2b3f92"], ["#fff6e6", "#e8bd78", "#8b5d1f"], ["#ffeef6", "#e7a3c4", "#8a3c62"]],
yesim: [["#e2ffee", "#3f9f6f", "#134a2d"], ["#8d939a", "#2b2f35", "#08090b"], ["#ffffff", "#efe9df", "#a59a88"], ["#ffe8c7", "#d38a3a", "#6e3c0d"]],
};
// A repeatable pseudo random number in [0, 1).
function random(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
// The stones of a pit or store, scattered around its centre on a sunflower spiral. The same count and seed always
// give the same picture, so stones only move when the count changes.
const Stones: FunctionComponent<{ count: number, seed: number, themeId: string }> = ({ count, seed, themeId }) => {
const palette = PALETTES[themeId] ?? PALETTES.ceviz;
const stones = [];
for (let i = 0; i < count; i++) {
const angle = i * 2.39996 + seed;
const distance = count === 1 ? 0 : Math.sqrt((i + 0.6) / count);
const x = 50 + Math.cos(angle) * distance * 33 + (random(seed + i) - 0.5) * 3;
const y = 50 + Math.sin(angle) * distance * 33 + (random(seed + i * 3) - 0.5) * 3;
const [light, body, dark] = palette[Math.floor(random(seed * 5 + i * 7.1) * palette.length)];
stones.push(<span key={i} className="st" style={{
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
background: `radial-gradient(circle at 34% 28%, ${light} 0%, ${body} 55%, ${dark} 100%)`,
}} />);
}
return <>{stones}</>;
};
export default Stones;

View File

@ -1,59 +1,22 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent } from "react"; import { FunctionComponent } from "react";
import { Context } from "../../context/context"; import { getMarkClasses, PitMarks } from "./PitView";
import { getColorByBrightness } from "@mancala/core"; import Stones from "./Stones";
import Util from "../../util/Util";
import { PitViewModel } from "@mancala/core";
import StoneView from "./StoneView";
// A store: the top player's on the left ("l"), the viewer's on the right ("r").
const StoreView: FunctionComponent<{ const StoreView: FunctionComponent<{
context: Context; index: number;
pitViewModel: PitViewModel; stoneCount: number;
gridColumn: string; side: "l" | "r";
gridRow: string; themeId: string;
}> = ({ context, pitViewModel, gridColumn, gridRow }) => { label: string;
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( marks: PitMarks;
<StoneView key={index} color={pitViewModel.stoneColor} /> }> = ({ index, stoneCount, side, themeId, label, marks }) => (
)); <div className={`store ${side}${getMarkClasses(marks)}`} role="img" aria-label={label}>
const textColor = getColorByBrightness( <Stones count={stoneCount} seed={index * 3.1 + 7} themeId={themeId} />
pitViewModel.pitColor, <span className="sbadge">{stoneCount}</span>
context.themeManager.theme.textColor, {!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
context.themeManager.theme.textLightColor </div>
); );
return (
<div
className="store"
style={{
background: pitViewModel.pitColor,
gridColumn: gridColumn,
gridRow: gridRow
}}>
{stones}
<span className="store-stone-count-text" style={{ color: textColor, transition: 'color 0.5s' }}>
{stones.length}
</span>
<style jsx>{`
.store {
margin: var(--board-spacing);
border-radius: calc(10 * var(--board-unit));
display: flex;
align-items: center;
justify-content: center;
align-content: center;
flex-wrap: wrap;
position: relative;
transition: background-color 0.5s;
}
.store-stone-count-text {
position: absolute;
bottom: calc(2 * var(--board-unit));
font-family: monospace;
font-weight: bold;
font-size: calc(2 * var(--board-unit));
}
`}</style>
</div>
);
};
export default StoreView; export default StoreView;

View File

@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
<BoardView <BoardView
game={createGameAtMove(baseGame, moves, moveNumber)} game={createGameAtMove(baseGame, moves, moveNumber)}
context={context} context={context}
boardId={boardViewModel.id}
boardViewModel={boardViewModel} boardViewModel={boardViewModel}
revert={revert} revert={revert}
onPitSelect={() => undefined} /> topName={name(topPlayer)}
bottomName={name(bottomPlayer)}
lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div> <div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
<div className="controls" style={{ color: textColor }}> <div className="controls" style={{ color: textColor }}>

View File

@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{
<BoardView <BoardView
game={game} game={game}
context={context} context={context}
boardId={id}
boardViewModel={boardViewModel} boardViewModel={boardViewModel}
revert={!!revert} revert={!!revert}
onPitSelect={() => undefined} topName={name(topPlayer)}
width={PREVIEW_BOARD_WIDTH} /> bottomName={name(bottomPlayer)} />
</div> </div>
{lines.map((line, index) => <div key={index} className="line">{line}</div>)} {lines.map((line, index) => <div key={index} className="line">{line}</div>)}
<style jsx>{` <style jsx>{`
@ -90,6 +89,7 @@ const GamePreviewCard: FunctionComponent<{
opacity: 0.6; opacity: 0.6;
} }
.board { .board {
width: 100%;
pointer-events: none; pointer-events: none;
} }
/* Built in a map callback, which styled-jsx does not scope. */ /* Built in a map callback, which styled-jsx does not scope. */

View File

@ -230,8 +230,9 @@ const GamePage: FunctionComponent<{
return; return;
} }
setHasOngoingAction(true); setHasOngoingAction(true);
boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor = const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
context.themeManager.theme.pitSelectedColor; selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
selectedPit.highlight = "selected";
updateBoardViewModel(boardViewModel); updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: pitIndexForUser }; const gameMove: GameMove = { index: pitIndexForUser };
context.rtmt.sendMessage(channel_game_move, gameMove); context.rtmt.sendMessage(channel_game_move, gameMove);
@ -350,10 +351,13 @@ const GamePage: FunctionComponent<{
{showBoardView && ( {showBoardView && (
<BoardView <BoardView
game={game} game={game}
boardId={isReviewing ? `review-${viewedMove}` : boardId}
boardViewModel={shownBoardViewModel!} boardViewModel={shownBoardViewModel!}
context={context} context={context}
onPitSelect={onPitSelect} onPitSelect={isSpectator ? undefined : onPitSelect}
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} /> revert={isPlayer2} />
)} )}
{showBoardView && ( {showBoardView && (

View File

@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test. - [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu, - [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
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.
- [ ] 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.
- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [ ] 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. - [ ] 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.