feat(frontend): lay out the home page in the new design

The game in progress comes first with a large mini board; live,
popular and recent games are grids of cards with the score, a mini
board and a status pill. Live and popular games are shown together;
live games are hidden while nothing is being played.
This commit is contained in:
Halit Aksoy 2026-10-16 10:21:46 +03:00
parent ce27c71176
commit 9619825605
13 changed files with 250 additions and 291 deletions

View File

@ -1,41 +0,0 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
import { getColorByBrightness } from "@mancala/core";
const Button: FunctionComponent<{
context: Context;
text: String;
onClick: () => void;
color: string;
}> = ({ context, text, color, onClick }) => {
const textColor = getColorByBrightness(
color,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<button
onClick={onClick}
style={{
backgroundColor: color,
color: textColor,
}}
>
<style jsx>{`
button {
margin: 5px;
padding: 10px;
border: none;
border-radius: 30px;
transition: color 0.5s;
transition: background-color 0.5s;
white-space: nowrap;
}
`}</style>
{text}
</button>
);
};
export default Button;

View File

@ -1,49 +1,39 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getColorByBrightness, getPlayerDisplayName, LiveGameSummary } from "@mancala/core";
import { getPlayerDisplayName, LiveGameSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import Button from "../Button";
import { Link } from "react-router-dom";
import MiniBoard, { getStores } from "./MiniBoard";
// Shown at the top of the home page while the user has a game in progress.
const ActiveGameBanner: FunctionComponent<{
context: Context,
game: LiveGameSummary,
userId: string,
onContinue: () => void,
}> = ({ context, game, userId, onContinue }) => {
}> = ({ game, userId, onContinue }) => {
const { t } = useTranslation();
const theme = context.themeManager.theme;
const textColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
const opponent = game.player1.userId === userId ? game.player2 : game.player1;
const turn = game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn");
const revert = game.player2.userId === userId;
const stores = getStores(game.board, revert);
const details = [
game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"),
t("MoveNumber", { number: game.moveCount }),
t("StoreScores", { mine: stores.bottom, theirs: stores.top }),
].join(" · ");
return (
<div className="banner" style={{ background: theme.boardColor, color: textColor }}>
<section className="hero" aria-label={t("GameInProgress")}>
<div>
<div className="title">{t("GameInProgress")}</div>
<div>{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}</div>
<span className="pill live">{t("GameInProgress")}</span>
<h1>{t("GameWithOpponent", { name: getPlayerDisplayName(opponent, t) ?? t("Anonymous") })}</h1>
<p>{details}</p>
<div className="row">
<button className="btn pri" onClick={onContinue}>{t("Continue")}</button>
</div>
<Button context={context} color={theme.pitColor} text={t("Continue")} onClick={onContinue} />
<style jsx>{`
.banner {
width: calc(100% - 32px);
max-width: 868px;
margin: 16px 16px 0;
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;
}
.title {
font-weight: bold;
margin-bottom: 4px;
}
`}</style>
</div>
<Link className="hero-board" to={`/game/${game.id}`} aria-label={t("OpenGame")}>
<MiniBoard board={game.board} revert={revert} large />
</Link>
</section>
);
};

View File

@ -0,0 +1,42 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getPlayerDisplayName, PlayerSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import MiniBoard, { getStores } from "./MiniBoard";
// A game as a card: the players with the score, a mini board, a status pill and details. Links to the game.
const GameCard: FunctionComponent<{
id: string,
player1: PlayerSummary,
player2: PlayerSummary,
board: number[],
// Show player 2's side at the bottom.
revert?: boolean,
status: string,
// Class of the status pill: "live", "win", "lose" or "".
statusKind: string,
details: string,
}> = ({ id, player1, player2, board, revert, status, statusKind, details }) => {
const { t } = useTranslation();
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2];
const stores = getStores(board, revert);
return (
<Link className="card" to={`/game/${id}`}
aria-label={`${name(topPlayer)} ${stores.top} – ${stores.bottom} ${name(bottomPlayer)}, ${status}`}>
<div className="vs">
<span className="pn">{name(topPlayer)}</span>
<span className="score">{`${stores.top} – ${stores.bottom}`}</span>
<span className="pn r">{name(bottomPlayer)}</span>
</div>
<MiniBoard board={board} revert={revert} />
<div className="meta">
<span className={"pill " + statusKind}>{status}</span>
<span>{details}</span>
</div>
</Link>
);
};
export default GameCard;

View File

@ -1,105 +0,0 @@
import * as React from "react";
import { FunctionComponent, useMemo } from "react";
import {
BoardViewModelFactory,
createGameFromBoard,
getColorByBrightness,
getPlayerDisplayName,
PitViewModelFactory,
PlayerSummary,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import BoardView from "../board/BoardView";
export const PREVIEW_BOARD_WIDTH = 260;
// A game as a card: the players, the real board at preview size, and a few lines of details.
const GamePreviewCard: FunctionComponent<{
context: Context,
id: string,
player1: PlayerSummary,
player2: PlayerSummary,
board: number[],
// Highlighted as the player to move.
turnPlayerId?: string,
// Show player 2's side at the bottom.
revert?: boolean,
lines: string[],
onClick: () => void,
}> = ({ context, id, player1, player2, board, turnPlayerId, revert, lines, onClick }) => {
const { t } = useTranslation();
const theme = context.themeManager.theme;
const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
const game = useMemo(() => createGameFromBoard({
id, player1Id: player1.userId, player2Id: player2.userId, turnPlayerId: turnPlayerId ?? player1.userId, board,
}), [id, player1.userId, player2.userId, turnPlayerId, board.join(",")]);
const boardViewModel = BoardViewModelFactory.create(id, board.map((stoneCount, index) => PitViewModelFactory.create({
id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor,
})));
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
const nameStyle = (player: PlayerSummary): React.CSSProperties =>
({ fontWeight: player.userId === turnPlayerId ? "bold" : "normal" });
const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2];
return (
<div className="card" onClick={onClick} role="link" style={{ background: theme.appBarBgColor, color: textColor }}>
<div className="players">
<span style={nameStyle(topPlayer)}>{name(topPlayer)}</span>
<span className="versus">{t("Versus")}</span>
<span style={nameStyle(bottomPlayer)}>{name(bottomPlayer)}</span>
</div>
<div className="board">
<BoardView
game={game}
context={context}
boardViewModel={boardViewModel}
revert={!!revert}
topName={name(topPlayer)}
bottomName={name(bottomPlayer)} />
</div>
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
<style jsx>{`
.card {
padding: 12px;
border-radius: 16px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: ${PREVIEW_BOARD_WIDTH + 24}px;
box-sizing: border-box;
transition: background-color 0.5s, transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.players {
display: flex;
gap: 6px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.versus {
opacity: 0.6;
}
.board {
width: 100%;
pointer-events: none;
}
/* Built in a map callback, which styled-jsx does not scope. */
.card :global(.line) {
font-size: 0.9em;
opacity: 0.85;
}
`}</style>
</div>
);
};
export default GamePreviewCard;

View File

@ -0,0 +1,70 @@
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 40px;
align-items: center;
padding: 32px;
border-radius: 32px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.hero h1 { margin: 14px 0 8px; font-size: 34px; line-height: 1.1; letter-spacing: -.02em; }
.hero p { margin: 0 0 22px; color: var(--muted); font-size: 16px; }
.hero .row { display: flex; gap: 10px; flex-wrap: wrap; }
.hero-board { display: block; text-decoration: none; border-radius: 26px; }
.hero-board:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.sec { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 18px 0 -10px; }
.sec h2 { margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.01em; }
.secmeta { font-size: 14px; color: var(--muted); }
.empty { margin: 0; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 24px;
background: var(--surface);
color: var(--ink);
text-decoration: none;
box-shadow: inset 0 0 0 1px var(--line);
transition: transform .2s, box-shadow .2s;
}
@media (hover: hover) {
.card:hover { transform: translateY(-3px); box-shadow: inset 0 0 0 1px var(--accentSoft), 0 16px 30px rgba(0, 0, 0, .18); }
}
.vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; font-weight: 700; font-size: 15px; }
.pn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pn.r { text-align: right; }
.score { font-weight: 800; font-size: 17px; font-variant-numeric: tabular-nums; padding: 2px 10px; border-radius: 10px; background: var(--surface2); white-space: nowrap; }
.meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); min-width: 0; }
.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.meta > span:last-child { text-align: right; }
/* Mini board: the top player's store on the left, the bottom player's on the right */
.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); }
.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); }
.mini .ms.l { grid-column: 1; }
.mini .ms.r { grid-column: 8; color: var(--accent); }
.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; }
.mini.lg .mp { font-size: 16px; }
.mini.lg .ms { font-size: 20px; padding-bottom: 14px; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; }
.hero h1 { font-size: 25px; }
.hero .btn { flex: 1; }
.mini.lg { gap: 6px; padding: 10px; border-radius: 20px; }
.mini.lg .mp { font-size: 13px; }
.mini.lg .ms { font-size: 15px; padding-bottom: 10px; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.sec { margin: 12px 0 -4px; }
}

View File

@ -1,44 +1,14 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getColorByBrightness } from "@mancala/core";
import { Context } from "../../context/context";
// A titled block of the home page; children are laid out as wrapping cards.
const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => {
const theme = props.context.themeManager.theme;
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
// A titled block of the home page; children are laid out as a grid of cards.
const HomeSection: FunctionComponent<{ title: string, subtitle: string, emptyText?: string }> = (props) => {
const isEmpty = React.Children.count(props.children) === 0;
return (
<section style={{ color: textColor }}>
<h3>{props.title}</h3>
{isEmpty && props.emptyText ? <p className="empty">{props.emptyText}</p> : <div className="cards">{props.children}</div>}
<style jsx>{`
section {
width: 100%;
max-width: 900px;
padding: 0 16px;
box-sizing: border-box;
}
h3 {
margin: 24px 0 12px;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: flex-start;
}
.empty {
opacity: 0.7;
margin: 0;
}
@media (max-width: 600px) {
.cards {
justify-content: center;
}
}
`}</style>
</section>
<>
<div className="sec"><h2>{props.title}</h2><span className="secmeta">{props.subtitle}</span></div>
{isEmpty && props.emptyText ? <p className="empty">{props.emptyText}</p> : <div className="grid">{props.children}</div>}
</>
);
};

View File

@ -7,80 +7,42 @@ import {
LiveGameSummary,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Context } from "../../context/context";
import { PopularGames } from "../../store/GameStore";
import { formatDate, getResultText, getScoreText } from "../../util/GameResultText";
import GamePreviewCard from "./GamePreviewCard";
import GameCard from "./GameCard";
import HomeSection from "./HomeSection";
const GAME_COUNT = 5;
// Live games, updated as they are played; when nothing is live, the most watched finished games.
// Live games, updated as they are played; hidden while nothing is being played.
const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [popular, setPopular] = useState<PopularGames | undefined>(undefined);
const { t } = useTranslation();
const [games, setGames] = useState<LiveGameSummary[]>([]);
useEffect(() => {
let disposed = false;
const loadPopular = () => context.gameStore.getPopularGames(GAME_COUNT)
.then((games) => !disposed && setPopular(games))
.catch((error) => console.error("(Home) Could not load popular games", error));
const onLiveGames = (message: { games: LiveGameSummary[] }) => {
if (message.games.length > 0) setPopular({ kind: "live", games: message.games });
else loadPopular();
};
const onLiveGames = (message: { games: LiveGameSummary[] }) => setGames(message.games);
// Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users.
const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {});
context.rtmt.addMessageListener(channel_on_live_games, onLiveGames);
context.rtmt.on("open", subscribe);
if (context.rtmt.connectionState === "connected") subscribe();
loadPopular();
return () => {
disposed = true;
context.rtmt.off("open", subscribe);
context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames);
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {});
};
}, []);
if (!popular) return <></>;
const openGame = (id: string) => navigate(`/game/${id}`);
if (popular.kind === "live") {
if (games.length === 0) return <></>;
return (
<HomeSection context={context} title={t("LiveGames")}>
{popular.games.map((game) => (
<GamePreviewCard
<HomeSection title={t("LiveGames")} subtitle={t("BeingPlayedNow")}>
{games.map((game) => (
<GameCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
turnPlayerId={game.turnPlayerId}
lines={[t("Watching", { count: game.spectatorCount }) + " · " + t("MoveNumber", { number: game.moveCount })]}
onClick={() => openGame(game.id)} />
))}
</HomeSection>
);
}
return (
<HomeSection context={context} title={t("PopularGames")} emptyText={t("NoGamesYet")}>
{popular.games.map((game) => (
<GamePreviewCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
lines={[
`${getResultText(game, undefined, t)} · ${getScoreText(game, undefined)}`,
`${formatDate(game.endedAt, i18n.language)} · ${t("Watching", { count: game.peakSpectators })}`,
]}
onClick={() => openGame(game.id)} />
status={t("Live")}
statusKind="live"
details={`${t("Watching", { count: game.spectatorCount })} · ${t("MoveNumber", { number: game.moveCount })}`} />
))}
</HomeSection>
);

View File

@ -0,0 +1,29 @@
import * as React from "react";
import { FunctionComponent } from "react";
const PLAYER1_PITS = [0, 1, 2, 3, 4, 5];
const PLAYER2_PITS = [7, 8, 9, 10, 11, 12];
const PLAYER1_STORE = 6;
const PLAYER2_STORE = 13;
// Stone counts on a small board, as the bottom player sees it. Empty pits show no number.
const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => {
const [topPits, topStore, bottomPits, bottomStore] = revert
? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE]
: [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE];
const pit = (index: number) => <div key={index} className="mp">{board[index] || ""}</div>;
return (
<div className={"mini" + (large ? " lg" : "")} aria-hidden="true">
<div className="ms l">{board[topStore]}</div>
{[...topPits].reverse().map(pit)}
<div className="ms r">{board[bottomStore]}</div>
{bottomPits.map(pit)}
</div>
);
};
export const getStores = (board: number[], revert?: boolean) => revert
? { top: board[PLAYER1_STORE], bottom: board[PLAYER2_STORE] }
: { top: board[PLAYER2_STORE], bottom: board[PLAYER1_STORE] };
export default MiniBoard;

View File

@ -0,0 +1,43 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import { FinishedGameSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { formatDate, getResultPillClass, getResultText } from "../../util/GameResultText";
import GameCard from "./GameCard";
import HomeSection from "./HomeSection";
const GAME_COUNT = 6;
// The most watched finished games.
const PopularGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t, i18n } = useTranslation();
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
useEffect(() => {
let disposed = false;
context.gameStore.getPopularFinishedGames(GAME_COUNT)
.then((popular) => !disposed && setGames(popular))
.catch((error) => console.error("(Home) Could not load popular games", error));
return () => { disposed = true; };
}, []);
if (!games) return <></>;
return (
<HomeSection title={t("PopularGames")} subtitle={t("MostWatched")} emptyText={t("NoGamesYet")}>
{games.map((game) => (
<GameCard
key={game.id}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
status={getResultText(game, undefined, t)}
statusKind={getResultPillClass(game, undefined)}
details={`${formatDate(game.endedAt, i18n.language)} · ${t("Watching", { count: game.peakSpectators })}`} />
))}
</HomeSection>
);
};
export default PopularGamesSection;

View File

@ -2,10 +2,9 @@ import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import { FinishedGameSummary } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Context } from "../../context/context";
import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
import GamePreviewCard from "./GamePreviewCard";
import { formatDate, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText";
import GameCard from "./GameCard";
import HomeSection from "./HomeSection";
const GAME_COUNT = 3;
@ -13,7 +12,6 @@ const GAME_COUNT = 3;
// The user's last finished games, each shown from their side of the board.
const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
useEffect(() => {
@ -27,21 +25,18 @@ const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }
if (!games) return <></>;
return (
<HomeSection context={context} title={t("MyRecentGames")} emptyText={t("NoGamesYet")}>
<HomeSection title={t("MyRecentGames")} subtitle={t("YourGames")} emptyText={t("NoGamesYet")}>
{games.map((game) => (
<GamePreviewCard
<GameCard
key={game.id}
context={context}
id={game.id}
player1={game.player1}
player2={game.player2}
board={game.board}
revert={getViewerSeat(game, userId) === "player2"}
lines={[
`${getResultText(game, userId, t)} · ${getScoreText(game, userId)}`,
formatDate(game.endedAt, i18n.language),
]}
onClick={() => navigate(`/game/${game.id}`)} />
status={getResultText(game, userId, t)}
statusKind={getResultPillClass(game, userId)}
details={formatDate(game.endedAt, i18n.language)} />
))}
</HomeSection>
);

View File

@ -8,6 +8,8 @@ import { PlusIcon } from "../components/Icons";
import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection";
import PopularGamesSection from "../components/home/PopularGamesSection";
import "../components/home/Home.css";
import { Context } from "../context/context";
import { useNavigate } from "react-router-dom";
import Util from "../util/Util";
@ -48,15 +50,17 @@ const Home: FunctionComponent<{
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button>
</AppHeader>
<main className="wrap">
{activeGame && authUser && (
<ActiveGameBanner
context={context}
game={activeGame}
userId={authUser.id}
onContinue={() => navigate(`/game/${activeGame.id}`)} />
)}
<LiveGamesSection context={context} />
<PopularGamesSection context={context} />
{authUser && <RecentGamesSection context={context} userId={authUser.id} />}
</main>
</PageContainer>
);
};

View File

@ -4,14 +4,13 @@ import { HttpServiceImpl } from "../service/HttpService";
export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord };
export type PopularGames = { kind: "live", games: LiveGameSummary[] } | { kind: "finished", games: FinishedGameSummary[] };
export interface GameStore {
// A live game, or the saved record of a finished one; undefined if there is no such game.
get(id: string): Promise<LoadedGame | undefined>;
getActiveGame(token: string): Promise<LiveGameSummary | null>;
getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]>;
getPopularGames(limit: number): Promise<PopularGames>;
// The most watched finished games, also while games are live.
getPopularFinishedGames(limit: number): Promise<FinishedGameSummary[]>;
}
export class GameStoreImpl implements GameStore {
@ -43,8 +42,9 @@ export class GameStoreImpl implements GameStore {
return json.games;
}
async getPopularGames(limit: number): Promise<PopularGames> {
return this.getJson(`/games/popular?limit=${limit}`);
async getPopularFinishedGames(limit: number): Promise<FinishedGameSummary[]> {
const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`);
return json.games;
}
private async getJson(route: string, token?: string): Promise<any> {

View File

@ -10,6 +10,6 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [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] 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.
- [x] 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`.