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:
parent
ce27c71176
commit
9619825605
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
42
apps/frontend/src/components/home/GameCard.tsx
Normal file
42
apps/frontend/src/components/home/GameCard.tsx
Normal 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;
|
||||
@ -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;
|
||||
70
apps/frontend/src/components/home/Home.css
Normal file
70
apps/frontend/src/components/home/Home.css
Normal 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; }
|
||||
}
|
||||
@ -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>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
29
apps/frontend/src/components/home/MiniBoard.tsx
Normal file
29
apps/frontend/src/components/home/MiniBoard.tsx
Normal 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;
|
||||
43
apps/frontend/src/components/home/PopularGamesSection.tsx
Normal file
43
apps/frontend/src/components/home/PopularGamesSection.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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`.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user