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 * as React from "react";
|
||||||
import { FunctionComponent } 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 { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { Link } from "react-router-dom";
|
||||||
import Button from "../Button";
|
import MiniBoard, { getStores } from "./MiniBoard";
|
||||||
|
|
||||||
// Shown at the top of the home page while the user has a game in progress.
|
// Shown at the top of the home page while the user has a game in progress.
|
||||||
const ActiveGameBanner: FunctionComponent<{
|
const ActiveGameBanner: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
game: LiveGameSummary,
|
game: LiveGameSummary,
|
||||||
userId: string,
|
userId: string,
|
||||||
onContinue: () => void,
|
onContinue: () => void,
|
||||||
}> = ({ context, game, userId, onContinue }) => {
|
}> = ({ game, userId, onContinue }) => {
|
||||||
const { t } = useTranslation();
|
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 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 (
|
return (
|
||||||
<div className="banner" style={{ background: theme.boardColor, color: textColor }}>
|
<section className="hero" aria-label={t("GameInProgress")}>
|
||||||
<div>
|
<div>
|
||||||
<div className="title">{t("GameInProgress")}</div>
|
<span className="pill live">{t("GameInProgress")}</span>
|
||||||
<div>{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button context={context} color={theme.pitColor} text={t("Continue")} onClick={onContinue} />
|
<Link className="hero-board" to={`/game/${game.id}`} aria-label={t("OpenGame")}>
|
||||||
<style jsx>{`
|
<MiniBoard board={game.board} revert={revert} large />
|
||||||
.banner {
|
</Link>
|
||||||
width: calc(100% - 32px);
|
</section>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 * as React from "react";
|
||||||
import { FunctionComponent } 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.
|
// A titled block of the home page; children are laid out as a grid of cards.
|
||||||
const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => {
|
const HomeSection: FunctionComponent<{ title: string, subtitle: string, emptyText?: string }> = (props) => {
|
||||||
const theme = props.context.themeManager.theme;
|
|
||||||
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
|
|
||||||
const isEmpty = React.Children.count(props.children) === 0;
|
const isEmpty = React.Children.count(props.children) === 0;
|
||||||
return (
|
return (
|
||||||
<section style={{ color: textColor }}>
|
<>
|
||||||
<h3>{props.title}</h3>
|
<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="cards">{props.children}</div>}
|
{isEmpty && props.emptyText ? <p className="empty">{props.emptyText}</p> : <div className="grid">{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,80 +7,42 @@ import {
|
|||||||
LiveGameSummary,
|
LiveGameSummary,
|
||||||
} from "@mancala/core";
|
} from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
import { PopularGames } from "../../store/GameStore";
|
import GameCard from "./GameCard";
|
||||||
import { formatDate, getResultText, getScoreText } from "../../util/GameResultText";
|
|
||||||
import GamePreviewCard from "./GamePreviewCard";
|
|
||||||
import HomeSection from "./HomeSection";
|
import HomeSection from "./HomeSection";
|
||||||
|
|
||||||
const GAME_COUNT = 5;
|
// Live games, updated as they are played; hidden while nothing is being played.
|
||||||
|
|
||||||
// Live games, updated as they are played; when nothing is live, the most watched finished games.
|
|
||||||
const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
|
const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const [games, setGames] = useState<LiveGameSummary[]>([]);
|
||||||
const [popular, setPopular] = useState<PopularGames | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
const onLiveGames = (message: { games: LiveGameSummary[] }) => setGames(message.games);
|
||||||
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();
|
|
||||||
};
|
|
||||||
// Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users.
|
// Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users.
|
||||||
const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {});
|
const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {});
|
||||||
context.rtmt.addMessageListener(channel_on_live_games, onLiveGames);
|
context.rtmt.addMessageListener(channel_on_live_games, onLiveGames);
|
||||||
context.rtmt.on("open", subscribe);
|
context.rtmt.on("open", subscribe);
|
||||||
if (context.rtmt.connectionState === "connected") subscribe();
|
if (context.rtmt.connectionState === "connected") subscribe();
|
||||||
loadPopular();
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
|
||||||
context.rtmt.off("open", subscribe);
|
context.rtmt.off("open", subscribe);
|
||||||
context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames);
|
context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames);
|
||||||
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {});
|
if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {});
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!popular) return <></>;
|
if (games.length === 0) return <></>;
|
||||||
const openGame = (id: string) => navigate(`/game/${id}`);
|
|
||||||
|
|
||||||
if (popular.kind === "live") {
|
|
||||||
return (
|
|
||||||
<HomeSection context={context} title={t("LiveGames")}>
|
|
||||||
{popular.games.map((game) => (
|
|
||||||
<GamePreviewCard
|
|
||||||
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 (
|
return (
|
||||||
<HomeSection context={context} title={t("PopularGames")} emptyText={t("NoGamesYet")}>
|
<HomeSection title={t("LiveGames")} subtitle={t("BeingPlayedNow")}>
|
||||||
{popular.games.map((game) => (
|
{games.map((game) => (
|
||||||
<GamePreviewCard
|
<GameCard
|
||||||
key={game.id}
|
key={game.id}
|
||||||
context={context}
|
|
||||||
id={game.id}
|
id={game.id}
|
||||||
player1={game.player1}
|
player1={game.player1}
|
||||||
player2={game.player2}
|
player2={game.player2}
|
||||||
board={game.board}
|
board={game.board}
|
||||||
lines={[
|
status={t("Live")}
|
||||||
`${getResultText(game, undefined, t)} · ${getScoreText(game, undefined)}`,
|
statusKind="live"
|
||||||
`${formatDate(game.endedAt, i18n.language)} · ${t("Watching", { count: game.peakSpectators })}`,
|
details={`${t("Watching", { count: game.spectatorCount })} · ${t("MoveNumber", { number: game.moveCount })}`} />
|
||||||
]}
|
|
||||||
onClick={() => openGame(game.id)} />
|
|
||||||
))}
|
))}
|
||||||
</HomeSection>
|
</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 { FunctionComponent, useEffect, useState } from "react";
|
||||||
import { FinishedGameSummary } from "@mancala/core";
|
import { FinishedGameSummary } from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Context } from "../../context/context";
|
import { Context } from "../../context/context";
|
||||||
import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
|
import { formatDate, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText";
|
||||||
import GamePreviewCard from "./GamePreviewCard";
|
import GameCard from "./GameCard";
|
||||||
import HomeSection from "./HomeSection";
|
import HomeSection from "./HomeSection";
|
||||||
|
|
||||||
const GAME_COUNT = 3;
|
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.
|
// The user's last finished games, each shown from their side of the board.
|
||||||
const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => {
|
const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
|
const [games, setGames] = useState<FinishedGameSummary[] | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -27,21 +25,18 @@ const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }
|
|||||||
|
|
||||||
if (!games) return <></>;
|
if (!games) return <></>;
|
||||||
return (
|
return (
|
||||||
<HomeSection context={context} title={t("MyRecentGames")} emptyText={t("NoGamesYet")}>
|
<HomeSection title={t("MyRecentGames")} subtitle={t("YourGames")} emptyText={t("NoGamesYet")}>
|
||||||
{games.map((game) => (
|
{games.map((game) => (
|
||||||
<GamePreviewCard
|
<GameCard
|
||||||
key={game.id}
|
key={game.id}
|
||||||
context={context}
|
|
||||||
id={game.id}
|
id={game.id}
|
||||||
player1={game.player1}
|
player1={game.player1}
|
||||||
player2={game.player2}
|
player2={game.player2}
|
||||||
board={game.board}
|
board={game.board}
|
||||||
revert={getViewerSeat(game, userId) === "player2"}
|
revert={getViewerSeat(game, userId) === "player2"}
|
||||||
lines={[
|
status={getResultText(game, userId, t)}
|
||||||
`${getResultText(game, userId, t)} · ${getScoreText(game, userId)}`,
|
statusKind={getResultPillClass(game, userId)}
|
||||||
formatDate(game.endedAt, i18n.language),
|
details={formatDate(game.endedAt, i18n.language)} />
|
||||||
]}
|
|
||||||
onClick={() => navigate(`/game/${game.id}`)} />
|
|
||||||
))}
|
))}
|
||||||
</HomeSection>
|
</HomeSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { PlusIcon } from "../components/Icons";
|
|||||||
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
||||||
import LiveGamesSection from "../components/home/LiveGamesSection";
|
import LiveGamesSection from "../components/home/LiveGamesSection";
|
||||||
import RecentGamesSection from "../components/home/RecentGamesSection";
|
import RecentGamesSection from "../components/home/RecentGamesSection";
|
||||||
|
import PopularGamesSection from "../components/home/PopularGamesSection";
|
||||||
|
import "../components/home/Home.css";
|
||||||
import { Context } from "../context/context";
|
import { Context } from "../context/context";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import Util from "../util/Util";
|
import Util from "../util/Util";
|
||||||
@ -48,15 +50,17 @@ const Home: FunctionComponent<{
|
|||||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
||||||
</button>
|
</button>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
{activeGame && authUser && (
|
<main className="wrap">
|
||||||
<ActiveGameBanner
|
{activeGame && authUser && (
|
||||||
context={context}
|
<ActiveGameBanner
|
||||||
game={activeGame}
|
game={activeGame}
|
||||||
userId={authUser.id}
|
userId={authUser.id}
|
||||||
onContinue={() => navigate(`/game/${activeGame.id}`)} />
|
onContinue={() => navigate(`/game/${activeGame.id}`)} />
|
||||||
)}
|
)}
|
||||||
<LiveGamesSection context={context} />
|
<LiveGamesSection context={context} />
|
||||||
{authUser && <RecentGamesSection context={context} userId={authUser.id} />}
|
<PopularGamesSection context={context} />
|
||||||
|
{authUser && <RecentGamesSection context={context} userId={authUser.id} />}
|
||||||
|
</main>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,14 +4,13 @@ import { HttpServiceImpl } from "../service/HttpService";
|
|||||||
|
|
||||||
export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord };
|
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 {
|
export interface GameStore {
|
||||||
// A live game, or the saved record of a finished one; undefined if there is no such game.
|
// A live game, or the saved record of a finished one; undefined if there is no such game.
|
||||||
get(id: string): Promise<LoadedGame | undefined>;
|
get(id: string): Promise<LoadedGame | undefined>;
|
||||||
getActiveGame(token: string): Promise<LiveGameSummary | null>;
|
getActiveGame(token: string): Promise<LiveGameSummary | null>;
|
||||||
getMyRecentGames(token: string, limit: number): Promise<FinishedGameSummary[]>;
|
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 {
|
export class GameStoreImpl implements GameStore {
|
||||||
@ -43,8 +42,9 @@ export class GameStoreImpl implements GameStore {
|
|||||||
return json.games;
|
return json.games;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPopularGames(limit: number): Promise<PopularGames> {
|
async getPopularFinishedGames(limit: number): Promise<FinishedGameSummary[]> {
|
||||||
return this.getJson(`/games/popular?limit=${limit}`);
|
const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`);
|
||||||
|
return json.games;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getJson(route: string, token?: string): Promise<any> {
|
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] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
|
||||||
- [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.
|
- [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.
|
||||||
- [x] 6. **Web app replay page**: summary card, controls card, move list.
|
- [x] 6. **Web app replay page**: summary card, controls card, move list.
|
||||||
- [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards.
|
- [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;
|
- [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes;
|
||||||
`AGENTS.md` and `TODO.md`.
|
`AGENTS.md` and `TODO.md`.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user