Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -1,21 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { FunctionComponent } from 'react';
|
|
||||||
|
|
||||||
const Center: FunctionComponent = ({children}) => {
|
|
||||||
return (
|
|
||||||
<div className='center'>
|
|
||||||
{children}
|
|
||||||
<style jsx>{`
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Center;
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { FunctionComponent } from 'react';
|
|
||||||
|
|
||||||
const CircularPanel: FunctionComponent<{
|
|
||||||
color: string;
|
|
||||||
style?: React.CSSProperties
|
|
||||||
}> = (props) => {
|
|
||||||
return (
|
|
||||||
<div style={Object.assign({ background: props.color }, props.style)}>
|
|
||||||
<style jsx>{`
|
|
||||||
div {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 30px;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CircularPanel;
|
|
||||||
@ -1,38 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { CLOCK_LOW_TIME_MS, formatClock, getColorByBrightness } from "@mancala/core";
|
import { CLOCK_LOW_TIME_MS, formatClock } from "@mancala/core";
|
||||||
import { Context } from '../context/context';
|
|
||||||
|
|
||||||
// Readable on every theme; the themes have no warning color of their own.
|
// One player's clock: highlighted while it runs, in a warning colour when the running clock is low.
|
||||||
const LOW_TIME_COLOR = "#b3261e";
|
|
||||||
const LOW_TIME_TEXT_COLOR = "#ffffff";
|
|
||||||
|
|
||||||
// One player's clock: highlighted while it runs, in a warning color when the running clock is low.
|
|
||||||
const GameClockView: FunctionComponent<{
|
const GameClockView: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
running: boolean,
|
running: boolean,
|
||||||
}> = ({ context, timeMs, running }) => {
|
}> = ({ timeMs, running }) => {
|
||||||
const theme = context.themeManager.theme;
|
|
||||||
const lowTime = running && timeMs < CLOCK_LOW_TIME_MS;
|
const lowTime = running && timeMs < CLOCK_LOW_TIME_MS;
|
||||||
const background = lowTime ? LOW_TIME_COLOR : running ? theme.playerTurnColor : "transparent";
|
|
||||||
const color = lowTime ? LOW_TIME_TEXT_COLOR : running
|
|
||||||
? getColorByBrightness(theme.playerTurnColor, theme.textColor, theme.textLightColor)
|
|
||||||
: getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
|
|
||||||
return (
|
return (
|
||||||
<span className="clock" style={{ background, color }} role="timer">
|
<span className={"clock" + (lowTime ? " low" : running ? " run" : "")} role="timer">
|
||||||
{formatClock(timeMs)}
|
{formatClock(timeMs)}
|
||||||
<style jsx>{`
|
|
||||||
.clock {
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 30px;
|
|
||||||
border: 2px solid ${theme.boardColor};
|
|
||||||
font-weight: bold;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background-color 0.5s, color 0.5s;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,121 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { FunctionComponent } from "react";
|
|
||||||
import { Context } from "../context/context";
|
|
||||||
import { Game, User } from "@mancala/core";
|
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
|
||||||
import CircularPanel from "./CircularPanel";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
function getInfoPanelTextByGameState(params: {
|
|
||||||
context: Context;
|
|
||||||
t: (text: string) => string;
|
|
||||||
game?: Game;
|
|
||||||
currentUser: User;
|
|
||||||
whitePlayer: User;
|
|
||||||
blackPlayer: User;
|
|
||||||
leftPlayer?: User;
|
|
||||||
isSpectator?: boolean;
|
|
||||||
}): string | undefined {
|
|
||||||
const {
|
|
||||||
context,
|
|
||||||
t,
|
|
||||||
game,
|
|
||||||
currentUser,
|
|
||||||
whitePlayer,
|
|
||||||
blackPlayer,
|
|
||||||
leftPlayer,
|
|
||||||
isSpectator
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
if (leftPlayer) {
|
|
||||||
return isSpectator ? `${leftPlayer.name} ${t("UserLeftTheGame")}` :
|
|
||||||
leftPlayer.id == currentUser.id ? t("YouLeftTheGame") : t("OpponentLeftTheGame");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGameEnded = game?.mancalaGame.state == "ended";
|
|
||||||
if (isGameEnded) {
|
|
||||||
const wonPlayerID = game.mancalaGame.getWonPlayerId();
|
|
||||||
let whoWon;
|
|
||||||
if (wonPlayerID) {
|
|
||||||
const wonPlayer = wonPlayerID == whitePlayer.id ? whitePlayer : blackPlayer;
|
|
||||||
whoWon = isSpectator ? `${wonPlayer.name} ${t("Won")}` :
|
|
||||||
game.mancalaGame.getWonPlayerId() === currentUser.id
|
|
||||||
? t("YouWon")
|
|
||||||
: t("YouLost");
|
|
||||||
} else {
|
|
||||||
whoWon = t("GameDraw");
|
|
||||||
}
|
|
||||||
return t("GameEnded") + " " + whoWon;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (game) {
|
|
||||||
const playingPlayer = game.mancalaGame.checkIsPlayerTurn(whitePlayer.id) ? whitePlayer : blackPlayer;
|
|
||||||
return isSpectator ? `${playingPlayer.name} ${t("Playing")}` : game.mancalaGame.checkIsPlayerTurn(currentUser.id)
|
|
||||||
? t("YourTurn")
|
|
||||||
: t("OpponentTurn");
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoPanel: FunctionComponent<{
|
|
||||||
context: Context;
|
|
||||||
game?: Game;
|
|
||||||
currentUser: User;
|
|
||||||
whitePlayer: User;
|
|
||||||
blackPlayer: User;
|
|
||||||
leftPlayer?: User;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
visible?: boolean;
|
|
||||||
isSpectator?: boolean;
|
|
||||||
// Replaces the text derived from the game, e.g. for games that ended by a timeout.
|
|
||||||
overrideText?: string;
|
|
||||||
}> = ({
|
|
||||||
context,
|
|
||||||
game,
|
|
||||||
currentUser,
|
|
||||||
whitePlayer,
|
|
||||||
blackPlayer,
|
|
||||||
leftPlayer,
|
|
||||||
style,
|
|
||||||
visible,
|
|
||||||
isSpectator,
|
|
||||||
overrideText
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (visible === false) return <></>;
|
|
||||||
const isUserTurn = currentUser.id ? game?.mancalaGame.checkIsPlayerTurn(currentUser.id) : false;
|
|
||||||
const containerColor = isUserTurn
|
|
||||||
? context.themeManager.theme.playerTurnColor
|
|
||||||
: context.themeManager.theme.boardColor;
|
|
||||||
const textColor = getColorByBrightness(
|
|
||||||
containerColor,
|
|
||||||
context.themeManager.theme.textColor,
|
|
||||||
context.themeManager.theme.textLightColor
|
|
||||||
);
|
|
||||||
const text = overrideText ?? getInfoPanelTextByGameState({
|
|
||||||
context,
|
|
||||||
t,
|
|
||||||
game,
|
|
||||||
currentUser,
|
|
||||||
whitePlayer,
|
|
||||||
blackPlayer,
|
|
||||||
leftPlayer,
|
|
||||||
isSpectator
|
|
||||||
});
|
|
||||||
if (text) {
|
|
||||||
return (
|
|
||||||
<CircularPanel style={style} color={containerColor}>
|
|
||||||
<h4 style={{ margin: "0", color: textColor, transition: 'color 0.5s' }}>
|
|
||||||
{text}
|
|
||||||
</h4>
|
|
||||||
</CircularPanel>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (<div></div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InfoPanel;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { FunctionComponent } from 'react';
|
|
||||||
|
|
||||||
const Space: FunctionComponent<{ width?: string, height?: string }> = ({ width, height }) => {
|
|
||||||
return (
|
|
||||||
<div style={{ width, height}}/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Space;
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { FunctionComponent } from 'react';
|
|
||||||
import { Context } from '../context/context';
|
|
||||||
import { User } from "@mancala/core";
|
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
|
||||||
import Space from './Space';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export type LayoutMode = "right" | "left";
|
|
||||||
|
|
||||||
const UserStatus: FunctionComponent<{
|
|
||||||
context: Context,
|
|
||||||
user: User,
|
|
||||||
layoutMode: LayoutMode,
|
|
||||||
visible?: boolean,
|
|
||||||
style?: React.CSSProperties,
|
|
||||||
// Shown after the online status, e.g. the player's clock.
|
|
||||||
accessory?: React.ReactNode,
|
|
||||||
}> = ({ context, user, layoutMode, visible, style, accessory }) => {
|
|
||||||
if (visible === false) return <></>;
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const textColorOnBoard = getColorByBrightness(
|
|
||||||
context.themeManager.theme.background,
|
|
||||||
context.themeManager.theme.textColor,
|
|
||||||
context.themeManager.theme.textLightColor
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div style={style} className={layoutMode === "right" ? "flex-rtl" : "flex-ltr"}>
|
|
||||||
<span style={{color: textColorOnBoard, transition: 'color 0.5s'}} className='text'>{user.isAnonymous ? t("Anonymous") : user.name}</span>
|
|
||||||
<Space width='5px' />
|
|
||||||
<div className={"circle " + (user.isOnline ? "online" : "offline")}></div>
|
|
||||||
{accessory && <><Space width='8px' />{accessory}</>}
|
|
||||||
<style jsx>{`
|
|
||||||
.online {
|
|
||||||
background-color: ${context.themeManager.theme.boardColor};
|
|
||||||
}
|
|
||||||
.offline {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
.circle {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
min-width: 15px;
|
|
||||||
min-height: 15px;
|
|
||||||
border-radius: 15px;
|
|
||||||
border: 2px solid ${context.themeManager.theme.boardColor};
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
transition: color 0.5s;
|
|
||||||
}
|
|
||||||
.flex-rtl {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.flex-ltr {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
font-weight: bold;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
color : "grey";
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UserStatus;
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { FunctionComponent } from 'react';
|
|
||||||
|
|
||||||
const BoardToolbar: FunctionComponent<{ visible?: boolean, style?: React.CSSProperties }> = ({ children, visible, style }) => {
|
|
||||||
if(visible === false) return <></>;
|
|
||||||
return (
|
|
||||||
<div style={style} className='toolbar'>
|
|
||||||
{children}
|
|
||||||
<style jsx>{`
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BoardToolbar;
|
|
||||||
33
apps/frontend/src/components/game/GameOverCard.tsx
Normal file
33
apps/frontend/src/components/game/GameOverCard.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CloseIcon } from "../Icons";
|
||||||
|
|
||||||
|
// Over the board when a game ends: the result, why it ended, the score, and where to go next.
|
||||||
|
const GameOverCard: FunctionComponent<{
|
||||||
|
title: string,
|
||||||
|
reason?: string,
|
||||||
|
score: string,
|
||||||
|
onNewGame: () => void,
|
||||||
|
onClose: () => void,
|
||||||
|
}> = ({ title, reason, score, onNewGame, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="over">
|
||||||
|
<div className="ocard" role="dialog" aria-label={title}>
|
||||||
|
<button className="btn icon ghost close" aria-label={t("Close")} onClick={onClose}><CloseIcon /></button>
|
||||||
|
<div className="otitle">{title}</div>
|
||||||
|
{reason && <div className="oreason">{reason}</div>}
|
||||||
|
<div className="oscore">{score}</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn pri" onClick={onNewGame}>{t("NewGame")}</button>
|
||||||
|
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameOverCard;
|
||||||
36
apps/frontend/src/components/game/PlayerCard.tsx
Normal file
36
apps/frontend/src/components/game/PlayerCard.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitials } from "../../util/PlayerText";
|
||||||
|
|
||||||
|
// A player beside the board: avatar with initials and online status, name, a line of details and their clock.
|
||||||
|
// The player to move is ringed. Aligned to the right, everything is mirrored.
|
||||||
|
const PlayerCard: FunctionComponent<{
|
||||||
|
name: string,
|
||||||
|
isGuest: boolean,
|
||||||
|
details: string,
|
||||||
|
active: boolean,
|
||||||
|
online?: boolean,
|
||||||
|
clock?: React.ReactNode,
|
||||||
|
alignRight?: boolean,
|
||||||
|
}> = ({ name, isGuest, details, active, online, clock, alignRight }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={"pl" + (active ? " on" : "") + (alignRight ? " r" : "")}>
|
||||||
|
<div className="av" aria-hidden="true">
|
||||||
|
{getInitials(name, isGuest)}
|
||||||
|
{online !== undefined && <span className={"presence" + (online ? " online" : "")} />}
|
||||||
|
</div>
|
||||||
|
<div className="pinfo">
|
||||||
|
<div className="pname">{name}</div>
|
||||||
|
<div className="psub">
|
||||||
|
{details}
|
||||||
|
{online !== undefined && <span className="visually-hidden">{` · ${online ? t("Online") : t("Offline")}`}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{clock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayerCard;
|
||||||
61
apps/frontend/src/components/history/MoveList.css
Normal file
61
apps/frontend/src/components/history/MoveList.css
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
.moves { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.mhead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mhead .muted { color: var(--muted); font-weight: 600; }
|
||||||
|
.mhead:focus-visible, .mrow:focus-visible { outline: 3px solid var(--accent); outline-offset: -3px; }
|
||||||
|
|
||||||
|
.chev { flex: none; transition: transform .2s; }
|
||||||
|
.chev.closed { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
.mlist { position: relative; max-height: 330px; overflow: auto; margin: 0; padding: 0 8px 8px; list-style: none; }
|
||||||
|
|
||||||
|
.mrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) { .mrow:hover { background: var(--surface2); } }
|
||||||
|
|
||||||
|
.mrow.on { background: var(--surface2); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.mrow.on .tag { background: var(--surface); }
|
||||||
|
.mrow.on .tag.x { background: var(--accent); }
|
||||||
|
.mrow.on .tag.c { background: var(--ink); }
|
||||||
|
|
||||||
|
.mn { width: 26px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.mdot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||||
|
.mname { font-weight: 700; min-width: 120px; max-width: 14em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mpit { color: var(--muted); }
|
||||||
|
.mrow .tags { margin-left: auto; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mname { min-width: 0; }
|
||||||
|
}
|
||||||
@ -1,26 +1,27 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
import { FunctionComponent, useEffect, useRef, useState } from "react";
|
||||||
import { getColorByBrightness, PlayedMove } from "@mancala/core";
|
import { describeMove, PlayedMove } from "@mancala/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
import { ChevronIcon } from "../Icons";
|
||||||
import { getMoveText } from "../../util/MoveText";
|
import MoveTags from "./MoveTags";
|
||||||
|
import "./MoveList.css";
|
||||||
|
|
||||||
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
|
// The moves of a game; the shown position is highlighted and any entry can be selected. Entry 0 is the start.
|
||||||
const MoveList: FunctionComponent<{
|
const MoveList: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
moves: PlayedMove[],
|
moves: PlayedMove[],
|
||||||
// Move whose position is shown.
|
// Move whose position is shown.
|
||||||
selectedMove: number,
|
selectedMove: number,
|
||||||
onSelect: (moveNumber: number) => void,
|
onSelect: (moveNumber: number) => void,
|
||||||
playerName: (playerId: string) => string,
|
playerName: (playerId: string) => string,
|
||||||
collapsedByDefault?: boolean,
|
// Moves of this player get an accent dot, the others a muted one.
|
||||||
}> = ({ context, moves, selectedMove, onSelect, playerName, collapsedByDefault }) => {
|
accentPlayerId?: string,
|
||||||
|
// Live games list the newest move first, replays the start first.
|
||||||
|
newestFirst?: boolean,
|
||||||
|
}> = ({ moves, selectedMove, onSelect, playerName, accentPlayerId, newestFirst }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(!!collapsedByDefault);
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
const selectedRef = useRef<HTMLLIElement>(null);
|
const selectedRef = useRef<HTMLLIElement>(null);
|
||||||
const listRef = useRef<HTMLOListElement>(null);
|
const listRef = useRef<HTMLOListElement>(null);
|
||||||
const theme = context.themeManager.theme;
|
|
||||||
const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
|
|
||||||
|
|
||||||
// Keep the shown move visible by scrolling the list only, never the page.
|
// Keep the shown move visible by scrolling the list only, never the page.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -32,89 +33,36 @@ const MoveList: FunctionComponent<{
|
|||||||
}
|
}
|
||||||
}, [selectedMove, moves.length, collapsed]);
|
}, [selectedMove, moves.length, collapsed]);
|
||||||
|
|
||||||
const entry = (moveNumber: number, label: React.ReactNode) => (
|
const entry = (moveNumber: number) => {
|
||||||
<li
|
const selected = moveNumber === selectedMove;
|
||||||
key={moveNumber}
|
const move = moveNumber > 0 ? moves[moveNumber - 1] : undefined;
|
||||||
ref={moveNumber === selectedMove ? selectedRef : undefined}
|
return (
|
||||||
className={moveNumber === selectedMove ? "selected" : ""}
|
<li key={moveNumber} ref={selected ? selectedRef : undefined}>
|
||||||
style={moveNumber === selectedMove ? { background: theme.boardColor } : undefined}
|
<button className={"mrow" + (selected ? " on" : "")} aria-current={selected} onClick={() => onSelect(moveNumber)}>
|
||||||
onClick={() => onSelect(moveNumber)}>
|
<span className="mn">{move ? `${moveNumber}.` : ""}</span>
|
||||||
{label}
|
{move && <span className="mdot" style={{ background: move.playerId === accentPlayerId ? "var(--accent)" : "var(--muted)" }} />}
|
||||||
|
{move && <span className="mname">{playerName(move.playerId)}</span>}
|
||||||
|
<span className="mpit">{move ? t("PitNumber", { pit: describeMove(moves, moveNumber).pit }) : t("Start")}</span>
|
||||||
|
{move && <MoveTags moves={moves} moveNumber={moveNumber} />}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveNumbers = Array.from({ length: moves.length + 1 }, (_, i) => i);
|
||||||
|
if (newestFirst) moveNumbers.reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="move-list" style={{ background: theme.appBarBgColor, color: textColor }}>
|
<section className="moves" aria-label={t("Moves")}>
|
||||||
<button className="header" style={{ color: textColor }} onClick={() => setCollapsed(!collapsed)}>
|
<button className="mhead" aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}>
|
||||||
{`${t("Moves")} (${moves.length}) ${collapsed ? "▸" : "▾"}`}
|
<span>
|
||||||
|
{`${t("Moves")} `}
|
||||||
|
<span className="muted">{`(${moves.length})${newestFirst ? ` · ${t("NewestFirst")}` : ""}`}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronIcon className={"chev" + (collapsed ? " closed" : "")} />
|
||||||
</button>
|
</button>
|
||||||
{!collapsed && (
|
{!collapsed && <ol className="mlist" ref={listRef}>{moveNumbers.map(entry)}</ol>}
|
||||||
<ol ref={listRef}>
|
</section>
|
||||||
{entry(0, <span className="start">{t("Start")}</span>)}
|
|
||||||
{moves.map((move, i) => entry(i + 1, (
|
|
||||||
<>
|
|
||||||
<span className="number">{i + 1}.</span>
|
|
||||||
<span className="player">{playerName(move.playerId)}</span>
|
|
||||||
<span className="details">{getMoveText(moves, i + 1, t)}</span>
|
|
||||||
</>
|
|
||||||
)))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
<style jsx>{`
|
|
||||||
.move-list {
|
|
||||||
/* The page is a flex column; without this the list is squeezed to almost nothing. */
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 16px;
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
ol {
|
|
||||||
position: relative;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 8px 8px;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
/* Rows are built in helper functions, which styled-jsx does not scope. */
|
|
||||||
.move-list :global(li) {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.move-list :global(li.selected) {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.move-list :global(.number) {
|
|
||||||
min-width: 2.5em;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.move-list :global(.player) {
|
|
||||||
min-width: 8em;
|
|
||||||
max-width: 12em;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.move-list :global(.details) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
20
apps/frontend/src/components/history/MoveTags.tsx
Normal file
20
apps/frontend/src/components/history/MoveTags.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { describeMove, PlayedMove } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// What happened in a move, as tags: extra turn, capture, stones added to the store, game over.
|
||||||
|
const MoveTags: FunctionComponent<{ moves: PlayedMove[], moveNumber: number }> = ({ moves, moveNumber }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const move = describeMove(moves, moveNumber);
|
||||||
|
return (
|
||||||
|
<span className="tags">
|
||||||
|
{move.extraTurn && <span className="tag x">{t("ExtraTurn")}</span>}
|
||||||
|
{move.capture && <span className="tag c">{t("Capture")}</span>}
|
||||||
|
{move.storeGain > 0 && <span className="tag">{t("StoreGain", { count: move.storeGain })}</span>}
|
||||||
|
{move.gameOver && <span className="tag">{t("GameOver")}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoveTags;
|
||||||
@ -151,7 +151,7 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
// Each player's clock after the shown move; games saved before the game clock have none.
|
// Each player's clock after the shown move; games saved before the game clock have none.
|
||||||
const clockTimes = getClockAtMove(record, moveNumber);
|
const clockTimes = getClockAtMove(record, moveNumber);
|
||||||
const renderClock = (player: PlayerSummary) => clockTimes && (
|
const renderClock = (player: PlayerSummary) => clockTimes && (
|
||||||
<GameClockView context={context} running={false}
|
<GameClockView running={false}
|
||||||
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
timeMs={player === record.player1 ? clockTimes.player1Ms : clockTimes.player2Ms} />
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -219,7 +219,6 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MoveList
|
<MoveList
|
||||||
context={context}
|
|
||||||
moves={moves}
|
moves={moves}
|
||||||
selectedMove={moveNumber}
|
selectedMove={moveNumber}
|
||||||
onSelect={jumpTo}
|
onSelect={jumpTo}
|
||||||
|
|||||||
@ -1,43 +1,21 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Context } from "../../context/context";
|
|
||||||
import Button from "../Button";
|
|
||||||
|
|
||||||
// Shown during a live game while an earlier position is on the board.
|
// Shown during a live game while an earlier position is on the board.
|
||||||
const ReviewBanner: FunctionComponent<{
|
const ReviewBanner: FunctionComponent<{
|
||||||
context: Context,
|
|
||||||
moveNumber: number,
|
moveNumber: number,
|
||||||
isYourTurn: boolean,
|
isYourTurn: boolean,
|
||||||
onBackToLive: () => void,
|
onBackToLive: () => void,
|
||||||
}> = ({ context, moveNumber, isYourTurn, onBackToLive }) => {
|
}> = ({ moveNumber, isYourTurn, onBackToLive }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = context.themeManager.theme;
|
|
||||||
const background = isYourTurn ? theme.playerTurnColor : theme.boardColor;
|
|
||||||
const textColor = getColorByBrightness(background, theme.textColor, theme.textLightColor);
|
|
||||||
return (
|
return (
|
||||||
<div className="review-banner" style={{ background, color: textColor }}>
|
<div className="review" role="status">
|
||||||
<span>
|
<span>
|
||||||
{`⟲ ${t("ViewingMove", { number: moveNumber })}`}
|
{t("ViewingMove", { number: moveNumber })}
|
||||||
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
|
{isYourTurn && <strong>{` · ${t("YourTurn")}`}</strong>}
|
||||||
</span>
|
</span>
|
||||||
<Button context={context} color={theme.pitColor} text={`${t("BackToLive")} ▶|`} onClick={onBackToLive} />
|
<button className="btn pri" onClick={onBackToLive}>{t("BackToLive")}</button>
|
||||||
<style jsx>{`
|
|
||||||
.review-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
width: calc(100% - 32px);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 16px 8px;
|
|
||||||
padding: 4px 8px 4px 16px;
|
|
||||||
border-radius: 30px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
//https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs
|
|
||||||
|
|
||||||
function getWindowDimensions() {
|
|
||||||
const { innerWidth: width, innerHeight: height } = window;
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useWindowDimensions() {
|
|
||||||
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleResize() {
|
|
||||||
setWindowDimensions(getWindowDimensions());
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return windowDimensions;
|
|
||||||
}
|
|
||||||
@ -4,22 +4,16 @@ import { FunctionComponent, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { PitAnimator } from '@mancala/core';
|
import { PitAnimator } from '@mancala/core';
|
||||||
import BoardToolbar from '../components/board/BoardToolbar';
|
|
||||||
import BoardView from '../components/board/BoardView';
|
import BoardView from '../components/board/BoardView';
|
||||||
import InfoPanel from '../components/InfoPanel';
|
|
||||||
import LoadingComponent from '../components/LoadingComponent';
|
import LoadingComponent from '../components/LoadingComponent';
|
||||||
import PageContainer from '../components/PageContainer';
|
import PageContainer from '../components/PageContainer';
|
||||||
import AppHeader from '../components/headerbar/AppHeader';
|
import AppHeader from '../components/headerbar/AppHeader';
|
||||||
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
import { LeaveIcon, PlusIcon } from '../components/Icons';
|
||||||
import UserStatus from '../components/UserStatus';
|
|
||||||
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
|
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
|
||||||
import { Context } from '../context/context';
|
import { Context } from '../context/context';
|
||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme } from '@mancala/core';
|
import { Theme, getPlayerDisplayName } from '@mancala/core';
|
||||||
import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core";
|
|
||||||
import { BoardViewModel } from "@mancala/core";
|
import { BoardViewModel } from "@mancala/core";
|
||||||
import Center from '../components/Center';
|
|
||||||
import notyf from '../util/Notyf';
|
import notyf from '../util/Notyf';
|
||||||
import swal from 'sweetalert';
|
import swal from 'sweetalert';
|
||||||
import Util from '../util/Util';
|
import Util from '../util/Util';
|
||||||
@ -32,6 +26,9 @@ import { getResultText } from '../util/GameResultText';
|
|||||||
import ReplayView from '../components/history/ReplayView';
|
import ReplayView from '../components/history/ReplayView';
|
||||||
import GameClockView from '../components/GameClockView';
|
import GameClockView from '../components/GameClockView';
|
||||||
import useGameClock from '../hooks/useGameClock';
|
import useGameClock from '../hooks/useGameClock';
|
||||||
|
import PlayerCard from '../components/game/PlayerCard';
|
||||||
|
import GameOverCard from '../components/game/GameOverCard';
|
||||||
|
import '../styles/game.css';
|
||||||
|
|
||||||
const GamePage: FunctionComponent<{
|
const GamePage: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -65,7 +62,8 @@ const GamePage: FunctionComponent<{
|
|||||||
// Set when the server says the game ended, for whatever reason.
|
// Set when the server says the game ended, for whatever reason.
|
||||||
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
const [gameEnd, setGameEnd] = useState<GameEndMessage | undefined>(undefined);
|
||||||
|
|
||||||
const { height, width } = useWindowDimensions();
|
// Set when the player closes the game over card to look at the board.
|
||||||
|
const [gameOverClosed, setGameOverClosed] = useState<boolean>(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -265,13 +263,6 @@ const GamePage: FunctionComponent<{
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const textColorOnAppBar = getColorByBrightness(
|
|
||||||
context.themeManager.theme.appBarBgColor,
|
|
||||||
context.themeManager.theme.textColor,
|
|
||||||
context.themeManager.theme.textLightColor
|
|
||||||
);
|
|
||||||
const isMobile = width < 600;
|
|
||||||
|
|
||||||
const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended"));
|
const renderNewGameBtn = isSpectator || (userKeyWhoLeave || gameEnd || !game || (game && game.mancalaGame.state == "ended"));
|
||||||
|
|
||||||
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
const moves = (mancalaGame?.history ?? []) as unknown as PlayedMove[];
|
||||||
@ -292,65 +283,96 @@ const GamePage: FunctionComponent<{
|
|||||||
.find((connectionInfo) => connectionInfo?.userId === userId);
|
.find((connectionInfo) => connectionInfo?.userId === userId);
|
||||||
return { userId, name: info?.name, isGuest: info?.isGuest ?? true };
|
return { userId, name: info?.name, isGuest: info?.isGuest ?? true };
|
||||||
};
|
};
|
||||||
// A game that ended by a timeout or inactivity is not over by the rules, so say why it ended.
|
|
||||||
const endText = gameEnd && (gameEnd.endReason === "timeout" || gameEnd.endReason === "inactive") && mancalaGame
|
// How the game ended, as a saved game would say it; from the server's message when it sent one.
|
||||||
? `${t("GameEnded")} · ${getResultText({
|
const getEndSummary = (): FinishedGameSummary | undefined => {
|
||||||
result: gameEnd.result, endReason: gameEnd.endReason,
|
if (!mancalaGame || !isGameOver) return undefined;
|
||||||
player1: playerInfo(mancalaGame.player1Id),
|
const player1 = playerInfo(mancalaGame.player1Id), player2 = playerInfo(mancalaGame.player2Id);
|
||||||
player2: playerInfo(mancalaGame.player2Id),
|
const seatOf = (playerId: string) => playerId === mancalaGame.player1Id ? "player1" : "player2";
|
||||||
} as FinishedGameSummary, isSpectator ? undefined : userKey, t)}`
|
if (gameEnd) return { result: gameEnd.result, endReason: gameEnd.endReason, player1, player2 } as FinishedGameSummary;
|
||||||
: undefined;
|
if (userKeyWhoLeave) {
|
||||||
|
const winner = userKeyWhoLeave === mancalaGame.player1Id ? "player2" : "player1";
|
||||||
|
return { result: winner, endReason: "left", leftUserId: userKeyWhoLeave, player1, player2 } as FinishedGameSummary;
|
||||||
|
}
|
||||||
|
const winnerId = mancalaGame.getWonPlayerId();
|
||||||
|
return { result: winnerId ? seatOf(winnerId) : "draw", endReason: "completed", player1, player2 } as FinishedGameSummary;
|
||||||
|
};
|
||||||
|
const endSummary = getEndSummary();
|
||||||
|
// "You won · Opponent left" becomes the title "You won" and the reason "Opponent left".
|
||||||
|
const [resultTitle, ...resultReasons] = endSummary
|
||||||
|
? getResultText(endSummary, isSpectator ? undefined : userKey, t).split(" · ")
|
||||||
|
: [];
|
||||||
|
const leftUserId = userKeyWhoLeave ?? gameEnd?.leftUserId;
|
||||||
|
const resultReason = resultReasons.join(" · ")
|
||||||
|
|| (isSpectator && leftUserId ? `${getPlayerName(leftUserId) ?? t("Anonymous")} ${t("UserLeftTheGame")}` : undefined);
|
||||||
|
|
||||||
const clockTimes = useGameClock(game, gameEnd);
|
const clockTimes = useGameClock(game, gameEnd);
|
||||||
const renderClock = (playerId: string) => {
|
const renderClock = (playerId: string) => {
|
||||||
const seat = mancalaGame && getSeat(mancalaGame, playerId);
|
const seat = mancalaGame && getSeat(mancalaGame, playerId);
|
||||||
if (!clockTimes || !seat) return undefined;
|
if (!clockTimes || !seat) return undefined;
|
||||||
const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
|
const timeMs = seat === "player1" ? clockTimes.player1Ms : clockTimes.player2Ms;
|
||||||
return <GameClockView context={context} timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
|
return <GameClockView timeMs={timeMs} running={clockTimes.runningSeat === seat} />;
|
||||||
};
|
};
|
||||||
const showBoardView = game && boardViewModel && userKey && true;
|
const showBoardView = game && boardViewModel && userKey && true;
|
||||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||||
const topLocatedUserName = getPlayerName(topLocatedUserId);
|
|
||||||
const bottomLocatedUserName = getPlayerName(bottomLocatedUserId);
|
|
||||||
const topLocatedUser = {
|
const topLocatedUser = {
|
||||||
id: topLocatedUserId,
|
id: topLocatedUserId,
|
||||||
name: topLocatedUserName ?? "Anonymous",
|
name: getPlayerName(topLocatedUserId) ?? t("Anonymous"),
|
||||||
|
isGuest: playerInfo(topLocatedUserId).isGuest,
|
||||||
isOnline: isUserOnline(topLocatedUserId),
|
isOnline: isUserOnline(topLocatedUserId),
|
||||||
isAnonymous: !topLocatedUserName
|
|
||||||
};
|
};
|
||||||
const bottomLocatedUser = {
|
const bottomLocatedUser = {
|
||||||
id: bottomLocatedUserId,
|
id: bottomLocatedUserId,
|
||||||
name: bottomLocatedUserName ?? "Anonymous",
|
name: getPlayerName(bottomLocatedUserId) ?? t("Anonymous"),
|
||||||
|
isGuest: playerInfo(bottomLocatedUserId).isGuest,
|
||||||
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
||||||
isAnonymous: !bottomLocatedUserName
|
|
||||||
};
|
};
|
||||||
const currentUser = isSpectator ? {
|
|
||||||
id: "2",
|
|
||||||
name: "Anonymous",
|
|
||||||
isOnline: context.rtmt.connectionState === "connected",
|
|
||||||
isAnonymous: true
|
|
||||||
} : bottomLocatedUser;
|
|
||||||
const leftPlayer = userKeyWhoLeave ? (userKeyWhoLeave === topLocatedUser.id ? topLocatedUser : bottomLocatedUser) : undefined;
|
|
||||||
if (replayRecord) {
|
if (replayRecord) {
|
||||||
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
return <ReplayView context={context} theme={theme} record={replayRecord} userKey={userKey} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTurnText = () => {
|
||||||
|
if (isGameOver) return resultTitle ? `${t("GameEnded")} · ${resultTitle}` : t("GameEnded");
|
||||||
|
if (isSpectator) return `${getPlayerName(mancalaGame!.turnPlayerId) ?? t("Anonymous")} ${t("Playing")}`;
|
||||||
|
return isYourTurn ? t("YourTurn") : t("OpponentTurn");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGame = () => {
|
||||||
|
const board = mancalaGame!.board;
|
||||||
|
const [topStore, bottomStore] = isPlayer2
|
||||||
|
? [board.player1BankIndex(), board.player2BankIndex()]
|
||||||
|
: [board.player2BankIndex(), board.player1BankIndex()];
|
||||||
|
const shownStones = (index: number) => shownBoardViewModel!.pits[index].stoneCount;
|
||||||
|
const playerCard = (user: typeof topLocatedUser, storeIndex: number, role: string, alignRight: boolean) => (
|
||||||
|
<PlayerCard
|
||||||
|
name={user.name}
|
||||||
|
isGuest={user.isGuest}
|
||||||
|
details={[role, t("StoreCount", { count: shownStones(storeIndex) })].filter(Boolean).join(" · ")}
|
||||||
|
active={!isGameOver && mancalaGame!.turnPlayerId === user.id}
|
||||||
|
online={user.isOnline}
|
||||||
|
clock={renderClock(user.id)}
|
||||||
|
alignRight={alignRight} />
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<div className="gl">
|
||||||
{renderHeaderBar()}
|
<div className="a-opp">{playerCard(topLocatedUser, topStore, isSpectator ? "" : t("Opponent"), false)}</div>
|
||||||
{renderMobileBoardToolbar()}
|
<div className="a-turn">
|
||||||
{buildBoardTopToolbar()}
|
<div className={"turn" + (isYourTurn ? " mine" : "")} role="status">
|
||||||
{showBoardView && isReviewing && (
|
{!isGameOver && <span className="dot" />}
|
||||||
|
{getTurnText()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="a-me">{playerCard(bottomLocatedUser, bottomStore, isSpectator ? "" : t("You"), true)}</div>
|
||||||
|
<div className="a-board">
|
||||||
|
{isReviewing && (
|
||||||
<ReviewBanner
|
<ReviewBanner
|
||||||
context={context}
|
|
||||||
moveNumber={viewedMove!}
|
moveNumber={viewedMove!}
|
||||||
isYourTurn={isYourTurn}
|
isYourTurn={isYourTurn}
|
||||||
onBackToLive={() => setViewedMove(undefined)} />
|
onBackToLive={() => setViewedMove(undefined)} />
|
||||||
)}
|
)}
|
||||||
{showBoardView && (
|
|
||||||
<BoardView
|
<BoardView
|
||||||
game={game}
|
game={game!}
|
||||||
boardViewModel={shownBoardViewModel!}
|
boardViewModel={shownBoardViewModel!}
|
||||||
context={context}
|
context={context}
|
||||||
onPitSelect={isSpectator ? undefined : onPitSelect}
|
onPitSelect={isSpectator ? undefined : onPitSelect}
|
||||||
@ -359,19 +381,35 @@ const GamePage: FunctionComponent<{
|
|||||||
topName={topLocatedUser.name}
|
topName={topLocatedUser.name}
|
||||||
bottomName={bottomLocatedUser.name}
|
bottomName={bottomLocatedUser.name}
|
||||||
revert={isPlayer2} />
|
revert={isPlayer2} />
|
||||||
|
{isGameOver && !gameOverClosed && (
|
||||||
|
<GameOverCard
|
||||||
|
title={resultTitle ?? t("GameEnded")}
|
||||||
|
reason={resultReason}
|
||||||
|
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
|
||||||
|
onNewGame={onNewGameClick}
|
||||||
|
onClose={() => setGameOverClosed(true)} />
|
||||||
)}
|
)}
|
||||||
{showBoardView && (
|
</div>
|
||||||
|
<div className="a-moves">
|
||||||
<MoveList
|
<MoveList
|
||||||
context={context}
|
|
||||||
moves={moves}
|
moves={moves}
|
||||||
selectedMove={isReviewing ? viewedMove! : moves.length}
|
selectedMove={isReviewing ? viewedMove! : moves.length}
|
||||||
onSelect={selectMove}
|
onSelect={selectMove}
|
||||||
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
|
playerName={(playerId) => getPlayerName(playerId) ?? t("Anonymous")}
|
||||||
collapsedByDefault={isMobile} />
|
accentPlayerId={bottomLocatedUserId}
|
||||||
)}
|
newestFirst />
|
||||||
<Center>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
{renderHeaderBar()}
|
||||||
|
<main className="wrap">
|
||||||
|
{showBoardView && renderGame()}
|
||||||
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
|
||||||
</Center>
|
</main>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -386,46 +424,6 @@ const GamePage: FunctionComponent<{
|
|||||||
</button>}
|
</button>}
|
||||||
</AppHeader>;
|
</AppHeader>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMobileBoardToolbar() {
|
|
||||||
return <BoardToolbar style={{ justifyContent: "center" }} visible={showBoardView && isMobile || false}>
|
|
||||||
{buildInfoPanel({ visible: isMobile })}
|
|
||||||
</BoardToolbar>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBoardTopToolbar() {
|
|
||||||
return <BoardToolbar style={{ alignItems: "flex-end" }} visible={showBoardView || false}>
|
|
||||||
<UserStatus style={{
|
|
||||||
marginBottom: "0.5rem", marginLeft: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
|
||||||
width: isMobile ? "40vw" : "30vw"
|
|
||||||
}} context={context} layoutMode="left" user={topLocatedUser} visible={showBoardView || false}
|
|
||||||
accessory={renderClock(topLocatedUserId)} />
|
|
||||||
{buildInfoPanel({ visible: !isMobile })}
|
|
||||||
<UserStatus style={{
|
|
||||||
marginBottom: "0.5rem", marginRight: "6%", maxWidth: isMobile ? "40vw" : "30vw",
|
|
||||||
width: isMobile ? "40vw" : "30vw"
|
|
||||||
}} context={context} layoutMode="right" user={bottomLocatedUser} visible={showBoardView || false}
|
|
||||||
accessory={renderClock(bottomLocatedUserId)} />
|
|
||||||
</BoardToolbar>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInfoPanel(params: { visible: boolean }) {
|
|
||||||
return (
|
|
||||||
<InfoPanel
|
|
||||||
// Long texts such as "Game Ended · You Won · Opponent ran out of time" wrap on phones.
|
|
||||||
style={{ marginTop: "0.5rem", marginBottom: "0.5rem", ...(isMobile && { whiteSpace: "normal", textAlign: "center" }) }}
|
|
||||||
context={context}
|
|
||||||
game={game}
|
|
||||||
currentUser={currentUser}
|
|
||||||
whitePlayer={topLocatedUser}
|
|
||||||
blackPlayer={bottomLocatedUser}
|
|
||||||
leftPlayer={leftPlayer}
|
|
||||||
visible={params.visible}
|
|
||||||
isSpectator={isSpectator}
|
|
||||||
overrideText={endText} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GamePage;
|
export default GamePage;
|
||||||
|
|
||||||
|
|||||||
174
apps/frontend/src/styles/game.css
Normal file
174
apps/frontend/src/styles/game.css
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/* Layout of the game and replay pages: the top player on the left, the turn in the middle, the bottom player on the
|
||||||
|
right, then the board and what is below it. On phones the bottom player moves below the board. */
|
||||||
|
|
||||||
|
.gl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
grid-template-areas: "opp turn me" "board board board" "moves moves moves";
|
||||||
|
gap: 26px 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-opp { grid-area: opp; min-width: 0; }
|
||||||
|
.a-turn { grid-area: turn; min-width: 0; }
|
||||||
|
.a-me { grid-area: me; justify-self: end; min-width: 0; }
|
||||||
|
.a-board { grid-area: board; position: relative; min-width: 0; }
|
||||||
|
.a-moves { grid-area: moves; width: 100%; max-width: 760px; justify-self: center; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Players */
|
||||||
|
|
||||||
|
.pl { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.pl.r { flex-direction: row-reverse; text-align: right; }
|
||||||
|
|
||||||
|
.av {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--chip);
|
||||||
|
color: var(--chipInk);
|
||||||
|
box-shadow: 0 0 0 2px var(--rim);
|
||||||
|
transition: box-shadow .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl.on .av { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
|
||||||
|
|
||||||
|
.presence {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence.online { background: var(--accent); border-color: var(--bg); }
|
||||||
|
|
||||||
|
.pinfo { min-width: 0; }
|
||||||
|
.pname { font-weight: 700; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.psub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
flex: none;
|
||||||
|
min-width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
transition: background .25s, color .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock.run { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
||||||
|
.clock.low { background: var(--live); color: #1a0d08; box-shadow: none; }
|
||||||
|
|
||||||
|
/* Turn */
|
||||||
|
|
||||||
|
.turn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: background .25s, color .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn.mine { background: var(--accent); color: var(--onAccent); box-shadow: none; }
|
||||||
|
|
||||||
|
/* Review banner, above the board */
|
||||||
|
|
||||||
|
.review {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
padding: 6px 6px 6px 18px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accentSoft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review strong { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Game over card, over the board */
|
||||||
|
|
||||||
|
.over {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 32px;
|
||||||
|
background: rgba(0, 0, 0, .38);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: calc(100% - 32px);
|
||||||
|
padding: 28px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, .35);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocard .close { position: absolute; top: 8px; right: 8px; }
|
||||||
|
.otitle { font-size: 30px; font-weight: 800; letter-spacing: -.02em; }
|
||||||
|
.oreason { font-size: 15px; font-weight: 700; color: var(--muted); }
|
||||||
|
.oscore { font-size: 18px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 8px; }
|
||||||
|
.ocard .row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
|
||||||
|
/* Tags of a move: extra turn, capture, stones to the store */
|
||||||
|
|
||||||
|
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.tag { height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; font-weight: 700; line-height: 24px; background: var(--surface2); color: var(--ink); white-space: nowrap; }
|
||||||
|
.tag.x { background: var(--accent); color: var(--onAccent); }
|
||||||
|
.tag.c { background: var(--ink); color: var(--bg); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.gl { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "opp turn" "board board" "me me" "moves moves"; gap: 16px 10px; }
|
||||||
|
.a-me { justify-self: start; }
|
||||||
|
.pl.r { flex-direction: row; text-align: left; }
|
||||||
|
.pl { gap: 8px; }
|
||||||
|
.clock { min-width: 50px; padding: 0 8px; font-size: 14px; }
|
||||||
|
.turn { min-height: 36px; padding: 6px 14px; font-size: 14px; white-space: normal; text-align: center; }
|
||||||
|
.review { margin-bottom: 16px; }
|
||||||
|
.ocard { padding: 26px 20px 20px; }
|
||||||
|
.otitle { font-size: 26px; }
|
||||||
|
}
|
||||||
7
apps/frontend/src/util/PlayerText.ts
Normal file
7
apps/frontend/src/util/PlayerText.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Up to two characters for an avatar: a guest's code ("Guest 4F2A" → "4F"), otherwise the first letter.
|
||||||
|
export function getInitials(name: string | undefined, isGuest: boolean): string {
|
||||||
|
const trimmed = name?.trim();
|
||||||
|
if (!trimmed) return "?";
|
||||||
|
const words = trimmed.split(/\s+/);
|
||||||
|
return isGuest && words.length > 1 ? words[words.length - 1].slice(0, 2).toUpperCase() : trimmed[0].toUpperCase();
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
|
|||||||
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
|
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
|
||||||
action) on every page; restyle the lobby, privacy page, loading and connection panels.
|
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] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
|
||||||
- [ ] 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.
|
||||||
- [ ] 6. **Web app replay page**: summary card, controls card, move list.
|
- [ ] 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.
|
- [ ] 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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user