diff --git a/apps/frontend/src/components/Button.tsx b/apps/frontend/src/components/Button.tsx
deleted file mode 100644
index 105917e..0000000
--- a/apps/frontend/src/components/Button.tsx
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default Button;
diff --git a/apps/frontend/src/components/home/ActiveGameBanner.tsx b/apps/frontend/src/components/home/ActiveGameBanner.tsx
index e8d0b78..8b6219c 100644
--- a/apps/frontend/src/components/home/ActiveGameBanner.tsx
+++ b/apps/frontend/src/components/home/ActiveGameBanner.tsx
@@ -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 (
-
+
-
{t("GameInProgress")}
-
{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}
+
{t("GameInProgress")}
+
{t("GameWithOpponent", { name: getPlayerDisplayName(opponent, t) ?? t("Anonymous") })}
+
{details}
+
+
+
-
-
-
+
+
+
+
);
};
diff --git a/apps/frontend/src/components/home/GameCard.tsx b/apps/frontend/src/components/home/GameCard.tsx
new file mode 100644
index 0000000..ce4e445
--- /dev/null
+++ b/apps/frontend/src/components/home/GameCard.tsx
@@ -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 (
+
+
+ {name(topPlayer)}
+ {`${stores.top} – ${stores.bottom}`}
+ {name(bottomPlayer)}
+
+
+
+ {status}
+ {details}
+
+
+ );
+};
+
+export default GameCard;
diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx
deleted file mode 100644
index 3708e03..0000000
--- a/apps/frontend/src/components/home/GamePreviewCard.tsx
+++ /dev/null
@@ -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 (
-
-
- {name(topPlayer)}
- {t("Versus")}
- {name(bottomPlayer)}
-
-
-
-
- {lines.map((line, index) =>
{line}
)}
-
-
- );
-};
-
-export default GamePreviewCard;
diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css
new file mode 100644
index 0000000..74fc073
--- /dev/null
+++ b/apps/frontend/src/components/home/Home.css
@@ -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; }
+}
diff --git a/apps/frontend/src/components/home/HomeSection.tsx b/apps/frontend/src/components/home/HomeSection.tsx
index 6f7a7a2..778f3f9 100644
--- a/apps/frontend/src/components/home/HomeSection.tsx
+++ b/apps/frontend/src/components/home/HomeSection.tsx
@@ -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 (
-
- {props.title}
- {isEmpty && props.emptyText ? {props.emptyText}
: {props.children}
}
-
-
+ <>
+ {props.title}
{props.subtitle}
+ {isEmpty && props.emptyText ? {props.emptyText}
: {props.children}
}
+ >
);
};
diff --git a/apps/frontend/src/components/home/LiveGamesSection.tsx b/apps/frontend/src/components/home/LiveGamesSection.tsx
index a52ffec..7d8ad65 100644
--- a/apps/frontend/src/components/home/LiveGamesSection.tsx
+++ b/apps/frontend/src/components/home/LiveGamesSection.tsx
@@ -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(undefined);
+ const { t } = useTranslation();
+ const [games, setGames] = useState([]);
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") {
- return (
-
- {popular.games.map((game) => (
- openGame(game.id)} />
- ))}
-
- );
- }
+ if (games.length === 0) return <>>;
return (
-
- {popular.games.map((game) => (
-
+ {games.map((game) => (
+ openGame(game.id)} />
+ status={t("Live")}
+ statusKind="live"
+ details={`${t("Watching", { count: game.spectatorCount })} · ${t("MoveNumber", { number: game.moveCount })}`} />
))}
);
diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx
new file mode 100644
index 0000000..b6e1332
--- /dev/null
+++ b/apps/frontend/src/components/home/MiniBoard.tsx
@@ -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) => {board[index] || ""}
;
+ return (
+
+
{board[topStore]}
+ {[...topPits].reverse().map(pit)}
+
{board[bottomStore]}
+ {bottomPits.map(pit)}
+
+ );
+};
+
+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;
diff --git a/apps/frontend/src/components/home/PopularGamesSection.tsx b/apps/frontend/src/components/home/PopularGamesSection.tsx
new file mode 100644
index 0000000..7e453d7
--- /dev/null
+++ b/apps/frontend/src/components/home/PopularGamesSection.tsx
@@ -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(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 (
+
+ {games.map((game) => (
+
+ ))}
+
+ );
+};
+
+export default PopularGamesSection;
diff --git a/apps/frontend/src/components/home/RecentGamesSection.tsx b/apps/frontend/src/components/home/RecentGamesSection.tsx
index 240111a..8201fc3 100644
--- a/apps/frontend/src/components/home/RecentGamesSection.tsx
+++ b/apps/frontend/src/components/home/RecentGamesSection.tsx
@@ -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(undefined);
useEffect(() => {
@@ -27,21 +25,18 @@ const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }
if (!games) return <>>;
return (
-
+
{games.map((game) => (
- navigate(`/game/${game.id}`)} />
+ status={getResultText(game, userId, t)}
+ statusKind={getResultPillClass(game, userId)}
+ details={formatDate(game.endedAt, i18n.language)} />
))}
);
diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx
index b9bc59f..e61acf6 100644
--- a/apps/frontend/src/routes/Home.tsx
+++ b/apps/frontend/src/routes/Home.tsx
@@ -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<{
{t("NewGame")}
- {activeGame && authUser && (
- navigate(`/game/${activeGame.id}`)} />
- )}
-
- {authUser && }
+
+ {activeGame && authUser && (
+ navigate(`/game/${activeGame.id}`)} />
+ )}
+
+
+ {authUser && }
+
);
};
diff --git a/apps/frontend/src/store/GameStore.ts b/apps/frontend/src/store/GameStore.ts
index 9fccc8a..0c8ca73 100644
--- a/apps/frontend/src/store/GameStore.ts
+++ b/apps/frontend/src/store/GameStore.ts
@@ -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;
getActiveGame(token: string): Promise;
getMyRecentGames(token: string, limit: number): Promise;
- getPopularGames(limit: number): Promise;
+ // The most watched finished games, also while games are live.
+ getPopularFinishedGames(limit: number): Promise;
}
export class GameStoreImpl implements GameStore {
@@ -43,8 +42,9 @@ export class GameStoreImpl implements GameStore {
return json.games;
}
- async getPopularGames(limit: number): Promise {
- return this.getJson(`/games/popular?limit=${limit}`);
+ async getPopularFinishedGames(limit: number): Promise {
+ const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`);
+ return json.games;
}
private async getJson(route: string, token?: string): Promise {
diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md
index 7e39c7f..49ddd0f 100644
--- a/docs/specs/web-redesign/tasks.md
+++ b/docs/specs/web-redesign/tasks.md
@@ -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`.