Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
32
apps/frontend/src/components/history/ReplayView.css
Normal file
32
apps/frontend/src/components/history/ReplayView.css
Normal file
@ -0,0 +1,32 @@
|
||||
.sum { display: flex; align-items: center; gap: 28px; padding: 22px 26px; border-radius: 28px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.sum-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.sum-info .pill { align-self: flex-start; }
|
||||
.sum-meta { font-size: 14px; color: var(--muted); }
|
||||
.sum-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; flex: 1; min-width: 0; }
|
||||
.sum-vs .pn { font-weight: 700; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sum-vs .pn.r { text-align: right; }
|
||||
.big { font-weight: 800; font-size: 34px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.ctl { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.ctlrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.tbtns { display: flex; align-items: center; gap: 8px; }
|
||||
.tb { width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--surface2); color: var(--ink); display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.tb.big { width: 54px; height: 54px; background: var(--accent); color: var(--onAccent); }
|
||||
.tb:disabled { opacity: .35; cursor: default; }
|
||||
.speed { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); }
|
||||
.segs { display: flex; gap: 2px; padding: 3px; border-radius: 24px; background: var(--surface2); }
|
||||
.seg { min-width: 46px; height: 38px; border: none; border-radius: 19px; background: transparent; color: var(--muted); font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; }
|
||||
.seg.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); }
|
||||
.range { width: 100%; height: 28px; margin: 0; accent-color: var(--accent); cursor: pointer; }
|
||||
.desc { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
|
||||
.desc b { color: var(--ink); }
|
||||
.tb:focus-visible, .seg:focus-visible, .range:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sum { flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; border-radius: 24px; }
|
||||
.sum .btn { width: 100%; }
|
||||
.big { font-size: 28px; }
|
||||
.sum-vs .pn { font-size: 15px; }
|
||||
.ctlrow { justify-content: center; }
|
||||
.tbtns { width: 100%; justify-content: space-between; }
|
||||
}
|
||||
@ -7,28 +7,31 @@ import {
|
||||
DEFAULT_ANIMATION_INTERVAL,
|
||||
GameRecord,
|
||||
getBoardAfterMove,
|
||||
describeMove,
|
||||
getClockAtMove,
|
||||
getColorByBrightness,
|
||||
getPlayerDisplayName,
|
||||
PitAnimator,
|
||||
PlayerSummary,
|
||||
Theme,
|
||||
} from "@mancala/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 } from "uuid";
|
||||
import { Context } from "../../context/context";
|
||||
import useMoveKeys from "../../hooks/useMoveKeys";
|
||||
import { createBoardViewModel } from "../../util/BoardViewModels";
|
||||
import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
|
||||
import { getMoveText } from "../../util/MoveText";
|
||||
import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText";
|
||||
import BoardView from "../board/BoardView";
|
||||
import Button from "../Button";
|
||||
import PageContainer from "../PageContainer";
|
||||
import AppHeader from "../headerbar/AppHeader";
|
||||
import { PlusIcon } from "../Icons";
|
||||
import { FirstIcon, LastIcon, NextIcon, PauseIcon, PlayIcon, PlusIcon, PreviousIcon, ReplayIcon } from "../Icons";
|
||||
import PlayerCard from "../game/PlayerCard";
|
||||
import GameClockView from "../GameClockView";
|
||||
import MoveList from "./MoveList";
|
||||
import MoveTags from "./MoveTags";
|
||||
import { GAME_STEP_GAME_MOVE } from "mancala.js";
|
||||
import "../../styles/game.css";
|
||||
import "./ReplayView.css";
|
||||
|
||||
const SPEEDS = [1, 2, 4];
|
||||
// Extra pause after a move's animation while playing, at 1x.
|
||||
@ -137,29 +140,42 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
||||
togglePlay,
|
||||
});
|
||||
|
||||
const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
|
||||
const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
|
||||
const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
|
||||
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
|
||||
const playerName = (playerId: string) =>
|
||||
name(playerId === record.player1.userId ? record.player1 : record.player2);
|
||||
const stones = getBoardAfterMove(moves, moveNumber);
|
||||
const [topPlayer, topStore, bottomPlayer, bottomStore] = revert
|
||||
? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]]
|
||||
: [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]];
|
||||
? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE]
|
||||
: [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE];
|
||||
const atEnd = moveNumber >= total;
|
||||
const shownMove = moveNumber > 0 ? moves[moveNumber - 1] : undefined;
|
||||
const nextMove = moves[moveNumber];
|
||||
// Where the shown move's last stone landed; not while a move is being animated.
|
||||
const isAnimating = boardViewModel.pits.some((pit) => pit.highlight);
|
||||
const sownSteps = shownMove?.gameSteps.filter((step) => step.type === GAME_STEP_GAME_MOVE) ?? [];
|
||||
const landIndex = !isAnimating && sownSteps.length > 0
|
||||
? sownSteps[sownSteps.length - 1].index % stones.length : undefined;
|
||||
// Each player's clock after the shown move; games saved before the game clock have none.
|
||||
const clockTimes = getClockAtMove(record, moveNumber);
|
||||
const renderClock = (player: PlayerSummary) => clockTimes && (
|
||||
<GameClockView running={false}
|
||||
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
||||
);
|
||||
const playerCard = (player: PlayerSummary, storeIndex: number, alignRight: boolean) => (
|
||||
<PlayerCard
|
||||
name={name(player)}
|
||||
isGuest={player.isGuest}
|
||||
details={[
|
||||
viewerId ? t(player.userId === viewerId ? "You" : "Opponent") : "",
|
||||
t("StoreCount", { count: stones[storeIndex] }),
|
||||
].filter(Boolean).join(" · ")}
|
||||
active={nextMove?.playerId === player.userId}
|
||||
clock={renderClock(player)}
|
||||
alignRight={alignRight} />
|
||||
);
|
||||
|
||||
const controlButton = (label: string, title: string, onClick: () => void, disabled = false) => (
|
||||
<button className="control" title={title} aria-label={title} disabled={disabled} onClick={onClick}
|
||||
style={{ background: theme.pitColor, color: getColorByBrightness(theme.pitColor, theme.textColor, theme.textLightColor) }}>
|
||||
{label}
|
||||
</button>
|
||||
const controlButton = (label: string, icon: React.ReactNode, onClick: () => void, disabled = false) => (
|
||||
<button className="tb" aria-label={label} disabled={disabled} onClick={onClick}>{icon}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -170,18 +186,33 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
||||
</button>
|
||||
</AppHeader>
|
||||
|
||||
<div className="result" style={{ background: theme.boardColor, color: panelTextColor }}>
|
||||
<div>
|
||||
<div className="result-title">{`${getResultText(record, viewerId, t)} · ${getScoreText(record, viewerId)}`}</div>
|
||||
<div>{`${formatDate(record.endedAt, i18n.language)} · ${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`}</div>
|
||||
<main className="wrap">
|
||||
<section className="sum" aria-label={t("GameSummary")}>
|
||||
<div className="sum-info">
|
||||
<span className={"pill " + getResultPillClass(record, viewerId)}>{getResultText(record, viewerId, t)}</span>
|
||||
<div className="sum-meta">
|
||||
{[formatDate(record.endedAt, i18n.language),
|
||||
`${t("Duration")} ${formatDuration(record.endedAt - record.startedAt)}`,
|
||||
t("MoveCount", { count: total })].join(" · ")}
|
||||
</div>
|
||||
{atEnd && !playing && total > 0 && (
|
||||
<Button context={context} color={theme.pitColor} text={`▶ ${t("WatchFromStart")}`}
|
||||
onClick={() => { showMove(0); setPlaying(true); }} />
|
||||
</div>
|
||||
<div className="sum-vs">
|
||||
<span className="pn">{name(topPlayer)}</span>
|
||||
<span className="big">{`${record.board[topStore]} – ${record.board[bottomStore]}`}</span>
|
||||
<span className="pn r">{name(bottomPlayer)}</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<button className="btn pri" onClick={() => { showMove(0); setPlaying(true); }}>
|
||||
<ReplayIcon />{t("WatchFromStart")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="player" style={{ color: textColor }}>{`${name(topPlayer)} · ${topStore}`}{renderClock(topPlayer)}</div>
|
||||
<div className="gl">
|
||||
<div className="a-opp">{playerCard(topPlayer, topStore, false)}</div>
|
||||
<div className="a-turn"><div className="turn" role="status">{t("MoveOf", { number: moveNumber, total })}</div></div>
|
||||
<div className="a-me">{playerCard(bottomPlayer, bottomStore, true)}</div>
|
||||
<div className="a-board">
|
||||
<BoardView
|
||||
game={createGameAtMove(baseGame, moves, moveNumber)}
|
||||
context={context}
|
||||
@ -189,127 +220,51 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
||||
revert={revert}
|
||||
topName={name(topPlayer)}
|
||||
bottomName={name(bottomPlayer)}
|
||||
lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
|
||||
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
|
||||
|
||||
<div className="controls" style={{ color: textColor }}>
|
||||
<div className="buttons">
|
||||
{controlButton("⏮", t("Start"), () => jumpTo(0), moveNumber === 0)}
|
||||
{controlButton("◀", "←", () => jumpTo(moveNumber - 1), moveNumber === 0)}
|
||||
{controlButton(playing ? "⏸" : "▶", playing ? "Pause" : "Play", togglePlay, total === 0)}
|
||||
{controlButton("▶|", "→", () => { setPlaying(false); animateNextMove(); }, atEnd)}
|
||||
{controlButton("⏭", t("GameOver"), () => jumpTo(total), atEnd)}
|
||||
<span className="speed">
|
||||
{`${t("Speed")}:`}
|
||||
lastMoveIndex={shownMove?.moveIndex}
|
||||
landIndex={landIndex} />
|
||||
</div>
|
||||
<div className="a-moves">
|
||||
<section className="ctl" aria-label={t("PlaybackControls")}>
|
||||
<div className="ctlrow">
|
||||
<div className="tbtns">
|
||||
{controlButton(t("FirstMove"), <FirstIcon />, () => jumpTo(0), moveNumber === 0)}
|
||||
{controlButton(t("PreviousMove"), <PreviousIcon />, () => jumpTo(moveNumber - 1), moveNumber === 0)}
|
||||
<button className="tb big" aria-label={playing ? t("Pause") : t("Play")} disabled={total === 0} onClick={togglePlay}>
|
||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
{controlButton(t("NextMove"), <NextIcon />, () => { setPlaying(false); animateNextMove(); }, atEnd)}
|
||||
{controlButton(t("LastMove"), <LastIcon />, () => jumpTo(total), atEnd)}
|
||||
</div>
|
||||
<div className="speed">
|
||||
<span>{t("Speed")}</span>
|
||||
<div className="segs" role="group" aria-label={t("PlaybackSpeed")}>
|
||||
{SPEEDS.map((s) => (
|
||||
<button key={s} className={"speed-option" + (s === speed ? " active" : "")}
|
||||
style={{ color: textColor }} onClick={() => setSpeed(s)}>{`${s}x`}</button>
|
||||
<button key={s} className={"seg" + (s === speed ? " on" : "")} aria-pressed={s === speed}
|
||||
onClick={() => setSpeed(s)}>{`${s}x`}</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="position">
|
||||
<span>{t("MoveOf", { number: moveNumber, total })}</span>
|
||||
<span className="move-text">
|
||||
{moveNumber === 0 ? t("Start") : `${playerName(moves[moveNumber - 1].playerId)} · ${getMoveText(moves, moveNumber, t)}`}
|
||||
</span>
|
||||
</div>
|
||||
<input className="timeline" type="range" min={0} max={total} value={moveNumber}
|
||||
style={{ accentColor: theme.boardColor }}
|
||||
aria-label={t("Moves")} onChange={(event) => jumpTo(Number(event.target.value))} />
|
||||
</div>
|
||||
<input className="range" type="range" min={0} max={total} value={moveNumber}
|
||||
aria-label={t("MovePosition")} onChange={(event) => jumpTo(Number(event.target.value))} />
|
||||
<div className="desc">
|
||||
<b>{moveNumber === 0 ? t("Start") : t("MoveNumber", { number: moveNumber })}</b>
|
||||
<span>{shownMove
|
||||
? `${playerName(shownMove.playerId)} · ${t("PitNumber", { pit: describeMove(moves, moveNumber).pit })}`
|
||||
: t("StartPosition")}</span>
|
||||
{shownMove && <MoveTags moves={moves} moveNumber={moveNumber} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MoveList
|
||||
moves={moves}
|
||||
selectedMove={moveNumber}
|
||||
onSelect={jumpTo}
|
||||
playerName={playerName} />
|
||||
|
||||
<style jsx>{`
|
||||
.result {
|
||||
flex-shrink: 0;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 868px;
|
||||
margin: 16px 16px 8px;
|
||||
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;
|
||||
}
|
||||
.result-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.player {
|
||||
margin: 6px 0;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.controls {
|
||||
flex-shrink: 0;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 640px;
|
||||
margin: 8px 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.position {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.move-text {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.timeline {
|
||||
width: 100%;
|
||||
}
|
||||
/* Built in helper functions and callbacks, which styled-jsx does not scope. */
|
||||
.controls :global(.control) {
|
||||
min-width: 44px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
.controls :global(.control:disabled) {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.speed {
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.controls :global(.speed-option) {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.controls :global(.speed-option.active) {
|
||||
opacity: 1;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}</style>
|
||||
playerName={playerName}
|
||||
accentPlayerId={viewerId ?? bottomPlayer.userId} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -20,6 +20,13 @@ export function getResultText(game: FinishedGameSummary, viewerId: string | unde
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// Class of the result's pill: "win" when someone won (and the viewer did not lose), "lose" when the viewer lost.
|
||||
export function getResultPillClass(game: FinishedGameSummary, viewerId: string | undefined): string {
|
||||
if (game.result === null || game.result === "draw") return "";
|
||||
const viewerSeat = getViewerSeat(game, viewerId);
|
||||
return viewerSeat && viewerSeat !== game.result ? "lose" : "win";
|
||||
}
|
||||
|
||||
// Final stones in the stores, the viewer's first when they played.
|
||||
export function getScoreText(game: FinishedGameSummary, viewerId: string | undefined): string {
|
||||
const player1 = game.board[PLAYER1_STORE], player2 = game.board[PLAYER2_STORE];
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { describeMove, PlayedMove } from "@mancala/core";
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// "Pit 3 · ★ Extra turn · +1 to store"
|
||||
export function getMoveText(moves: PlayedMove[], moveNumber: number, t: Translate): string {
|
||||
const move = describeMove(moves, moveNumber);
|
||||
const events = [
|
||||
move.extraTurn ? `★ ${t("ExtraTurn")}` : undefined,
|
||||
move.capture ? `✂ ${t("Capture")}` : undefined,
|
||||
move.storeGain > 0 ? t("StoreGain", { count: move.storeGain }) : undefined,
|
||||
move.gameOver ? `⚑ ${t("GameOver")}` : undefined,
|
||||
].filter(Boolean);
|
||||
return [t("PitNumber", { pit: move.pit }), ...events].join(" · ");
|
||||
}
|
||||
@ -9,7 +9,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
|
||||
action) on every page; restyle the lobby, privacy page, loading and connection panels.
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
- [ ] 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