Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
23 changed files with 655 additions and 455 deletions
Showing only changes of commit 6279b5a820 - Show all commits

View File

@ -11,7 +11,6 @@ import Home from './routes/Home';
import { initContext } from './context/context';
import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core';
import { getColorByBrightness } from "@mancala/core";
import { Theme } from '@mancala/core';
import LobyPage from './routes/LobyPage';
import PrivacyPage from './routes/PrivacyPage';
@ -19,6 +18,8 @@ import swal from 'sweetalert';
import Util from './util/Util';
import { server } from './const/config';
import { useTranslation } from 'react-i18next';
import { applyThemeClass } from './theme/themes';
import './styles/app.css';
const context = initContext();
@ -42,7 +43,10 @@ const MancalaApp: FunctionComponent = () => {
const onConnectionChange = (_connectionState: ConnectionState) => setConnetionState(_connectionState);
const onThemeChange = (theme: Theme) => setTheme(theme);
const onThemeChange = (theme: Theme) => {
applyThemeClass(theme);
setTheme(theme);
};
React.useEffect(() => {
//@ts-ignore
@ -99,6 +103,7 @@ const MancalaApp: FunctionComponent = () => {
}
React.useEffect(() => {
applyThemeClass(context.themeManager.theme);
startSession().catch((error) => {
swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error");
console.error(error);
@ -112,11 +117,6 @@ const MancalaApp: FunctionComponent = () => {
};
}, []);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<>
{hasSession && <BrowserRouter>
@ -133,8 +133,8 @@ const MancalaApp: FunctionComponent = () => {
</Route>
</Routes>
</BrowserRouter>}
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={connectionState != "connected"}>
<span style={{ color: textColorOnBoard, transition: 'color 0.5s' }}>{Util.getTextByConnectionState(context, connectionState, t)}</span>
<FloatingPanel visible={connectionState != "connected"}>
{Util.getTextByConnectionState(context, connectionState, t)}
</FloatingPanel>
</>
);

View File

@ -1,32 +1,38 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../context/context";
const FloatingPanel: FunctionComponent<{
context: Context;
color: string;
visible: boolean;
}> = (props) => {
if(props.visible === false) return <></>
// A status pill in the bottom left corner, e.g. while connecting.
const FloatingPanel: FunctionComponent<{ visible: boolean }> = (props) => {
if (props.visible === false) return <></>
return (
<div style={{
backgroundColor: props.color,
}}>
<div className="floating-panel" role="status">
<span className="dot" />
{props.children}
<style jsx>{`
div {
position: absolute;
bottom: 0px;
left: 0px;
padding: 15px;
border-top-right-radius: 1vw;
min-width: 10vw;
min-height: 1vw;
.floating-panel {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 18px;
box-sizing: border-box;
border-radius: 22px;
background: var(--surface);
color: var(--ink);
font-weight: 700;
font-size: 14px;
box-shadow: inset 0 0 0 1px var(--line), 0 12px 30px rgba(0, 0, 0, .3);
}
.floating-panel .dot {
color: var(--accent);
}
`}</style>
{props.children}
</div>
)
};
export default FloatingPanel;
export default FloatingPanel;

View File

@ -0,0 +1,69 @@
import * as React from "react";
// Inline stroke icons in the current text colour. All are decorative; buttons carry their own labels.
type IconProps = { size?: number, className?: string };
const stroke = (size: number, width: number, className?: string) => ({
width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: width,
strokeLinecap: "round" as const, strokeLinejoin: "round" as const, className, "aria-hidden": true,
});
export const LogoIcon = ({ size = 34 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
<rect x="1" y="1" width="30" height="30" rx="9" style={{ fill: "var(--accent)" }} />
<rect x="6" y="7" width="6" height="18" rx="3" style={{ fill: "var(--onAccent)" }} />
<circle cx="18" cy="11" r="3.2" style={{ fill: "var(--onAccent)" }} />
<circle cx="18" cy="21" r="3.2" style={{ fill: "var(--onAccent)" }} />
<circle cx="25" cy="11" r="3.2" style={{ fill: "var(--onAccent)", opacity: .55 }} />
<circle cx="25" cy="21" r="3.2" style={{ fill: "var(--onAccent)" }} />
</svg>
);
export const PlusIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.4)}><path d="M12 5v14M5 12h14" /></svg>
);
export const ProfileIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><circle cx="12" cy="8" r="4" /><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6" /></svg>
);
export const LeaveIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><path d="M14 4h5v16h-5" /><path d="M10 8l-4 4 4 4" /><path d="M6 12h10" /></svg>
);
export const ChevronIcon = ({ size = 20, className }: IconProps) => (
<svg {...stroke(size, 2.2, className)}><path d="M6 9l6 6 6-6" /></svg>
);
export const CloseIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.2)}><path d="M6 6l12 12M18 6L6 18" /></svg>
);
export const ReplayIcon = ({ size = 18 }: IconProps) => (
<svg {...stroke(size, 2.2)}><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
);
export const FirstIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.2)}><path d="M6 5v14" /><path d="M18 6l-8 6 8 6z" /></svg>
);
export const PreviousIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.4)}><path d="M15 6l-6 6 6 6" /></svg>
);
export const NextIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.4)}><path d="M9 6l6 6-6 6" /></svg>
);
export const LastIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2.2)}><path d="M18 5v14" /><path d="M6 6l8 6-8 6z" /></svg>
);
export const PlayIcon = ({ size = 22 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l11 7-11 7z" fill="currentColor" /></svg>
);
export const PauseIcon = ({ size = 22 }: IconProps) => (
<svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg>
);

View File

@ -1,32 +1,33 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
import { Context } from '../context/context';
import { LoadingState } from "@mancala/core";
import { getColorByBrightness } from "@mancala/core";
import CircularPanel from './CircularPanel';
import { useTranslation } from 'react-i18next';
const LoadingComponent: FunctionComponent<{ context: Context, loadingState: LoadingState<any> }> = ({ context, loadingState }) => {
const LoadingComponent: FunctionComponent<{ loadingState: LoadingState<any> }> = ({ loadingState }) => {
const { t } = useTranslation();
if (loadingState.isUnset() || loadingState.isLoaded()) {
return <></>
}
if (loadingState.isLoading() || loadingState.isError()) {
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const text = loadingState.isLoading() ? t("Loading") +"..." : loadingState.errorMessage;
return (
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${text}`}</h4>
</CircularPanel>
);
}
return <></>
if (!loadingState.isLoading() && !loadingState.isError()) return <></>;
const text = loadingState.isLoading() ? t("Loading") + "..." : loadingState.errorMessage;
return (
<div className="loading panel" role="status">
{loadingState.isLoading() && <span className="dot" />}
{text}
<style jsx>{`
.loading {
align-self: center;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px;
font-weight: 700;
}
.dot {
color: var(--accent);
}
`}</style>
</div>
);
}
export default LoadingComponent;
export default LoadingComponent;

View File

@ -1,25 +1,7 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Theme } from "@mancala/core";
const PageContainer: FunctionComponent<{ theme: Theme }> = (props) => {
return (
<div style={{
background: props.theme?.background,
}}>
<style jsx>{`
div {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-height: 400px;
transition: background-color 0.5s;
}
`}</style>
{props.children}
</div>
);
}
// A page: the header, then the page's content.
const PageContainer: FunctionComponent = ({ children }) => <div className="page">{children}</div>;
export default PageContainer;
export default PageContainer;

View File

@ -1,20 +0,0 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const Row: FunctionComponent = ({children}) => {
return (
<div className="row">
{children}
<style jsx>{`
.row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
`}</style>
</div>
);
}
export default Row;

View File

@ -4,46 +4,30 @@ import { FunctionComponent } from "react";
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { ProfileIcon } from "../Icons";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css"
// Guests can log in or sign up; logged in users can log out.
const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => {
const { context, textColor, authUser } = props;
const AccountMenu: FunctionComponent<{ context: Context, authUser: AuthUser }> = ({ context, authUser }) => {
const { t } = useTranslation();
if (!authUser) return <></>;
const authService = context.authService;
const menuButton = <span
style={{ color: textColor, cursor: 'pointer', userSelect: 'none' }}
className="material-symbols-outlined">
account_circle
</span>;
const itemStyle = { color: textColor };
const menuButton = (
<button className="btn icon ghost" aria-label={t("Account")}>
<ProfileIcon />
</button>
);
const menuItems = authUser.isGuest ? [
<MenuItem key="login" style={itemStyle} onClick={() => authService.login()}>{t("LogIn")}</MenuItem>,
<MenuItem key="signup" style={itemStyle} onClick={() => authService.signUp()}>{t("SignUp")}</MenuItem>,
<MenuItem key="login" onClick={() => authService.login()}>{t("LogIn")}</MenuItem>,
<MenuItem key="signup" onClick={() => authService.signUp()}>{t("SignUp")}</MenuItem>,
] : [
<MenuItem key="logout" style={itemStyle} onClick={() => authService.logout()}>{t("LogOut")}</MenuItem>,
<MenuItem key="logout" onClick={() => authService.logout()}>{t("LogOut")}</MenuItem>,
];
return (
<div className="menu-container">
<Menu
menuStyle={{ background: context.themeManager.theme.appBarBgColor }}
menuButton={menuButton}
transition
align="end">
<MenuHeader style={itemStyle}>{getPlayerDisplayName(authUser, t)}</MenuHeader>
{menuItems}
</Menu>
<style jsx>{`
.menu-container {
margin: 0 1vh;
display: flex;
align-items: center;
}
`}</style>
</div>
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader>
{menuItems}
</Menu>
);
}

View File

@ -0,0 +1,28 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Link } from "react-router-dom";
import { AuthUser } from "@mancala/core";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { LogoIcon } from "../Icons";
import AccountMenu from "./AccountMenu";
import ThemeSwatches from "./ThemeSwatches";
// The header of every page: logo and name, theme swatches, the account menu when a user is given, and the page's
// main action (children).
const AppHeader: FunctionComponent<{ context: Context, authUser?: AuthUser }> = ({ context, authUser, children }) => {
const { t } = useTranslation();
return (
<header className={"hdr" + (authUser ? " has-account" : "")}>
<Link className="logo" to="/" aria-label={`${t("Mancala")} · ${t("Home")}`}>
<LogoIcon />
<span>{t("Mancala")}</span>
</Link>
<ThemeSwatches context={context} />
{authUser && <AccountMenu context={context} authUser={authUser} />}
{children}
</header>
);
};
export default AppHeader;

View File

@ -1,21 +0,0 @@
import * as React from "react";
import { FunctionComponent } from "react";;
const HeaderBar: FunctionComponent<{ color?: string }> = ({children, color }) => {
return (
<div style={{ background: color }} className="header-bar">
{children}
<style jsx>{`
.header-bar {
padding: 0px 4vw;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
align-self: stretch;
transition: background-color 0.5s;
}
`}</style>
</div>)
}
export default HeaderBar;

View File

@ -1,22 +0,0 @@
import * as React from "react";
import { FunctionComponent } from "react";
//@ts-ignore
import MancalaIcon from "jsx:../../mancala.svg";
const HeaderbarIcon: FunctionComponent = () => {
return (
<div className="header-bar-icon-wrapper">
<MancalaIcon style={{ height: "30px", width: "30px" }} />
<style jsx>{`
.header-bar-icon-wrapper {
margin-right: 10px;
display: flex;
flex-direction: row;
align-items: center;
}
`}</style>
</div>
);
}
export default HeaderbarIcon;

View File

@ -1,17 +0,0 @@
import * as React from 'react';
import { FunctionComponent } from 'react';
const HeaderbarTitle: FunctionComponent<{ title: string, color: string }> = ({ title, color }) => {
return (
<h1 style={{ color: color, transition: 'color 0.5s' }} className="header-bar-title">
{title}
<style jsx>{`
.header-bar-title {
margin: 10px 0px;
}
`}</style>
</h1>
);
}
export default HeaderbarTitle;

View File

@ -0,0 +1,26 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { useTranslation } from "react-i18next";
import { Context } from "../../context/context";
import { getWebTheme, webThemes } from "../../theme/themes";
const ThemeSwatches: FunctionComponent<{ context: Context }> = ({ context }) => {
const { t } = useTranslation();
const current = getWebTheme(context.themeManager.theme);
return (
<div className="themes" role="group" aria-label={t("ChooseTheme")}>
{webThemes.map((theme) => (
<button
key={theme.id}
className={"sw" + (theme === current ? " on" : "")}
aria-label={t(theme.labelKey)}
aria-pressed={theme === current}
onClick={() => (context.themeManager.theme = theme)}>
<span style={{ background: `linear-gradient(135deg, ${theme.swatch.board} 0 50%, ${theme.swatch.accent} 50% 100%)` }} />
</button>
))}
</div>
);
};
export default ThemeSwatches;

View File

@ -1,64 +0,0 @@
import { Menu, MenuItem } from "@szhsin/react-menu";
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import "@szhsin/react-menu/dist/index.css";
import "@szhsin/react-menu/dist/transitions/slide.css"
const ThemeSwitchMenu: FunctionComponent<{ context: Context, textColor: string }> = (props) => {
const { context, textColor } = props;
const menuButton = <span
style={{ color: textColor, cursor: 'pointer', userSelect: 'none' }}
className="material-symbols-outlined">
light_mode
</span>;
const menuItems = context.themeManager.themes.map((theme, index) => {
return (
<MenuItem
key={index}
style={{ color: textColor }}
//@ts-ignore
onMouseOver={(event) => {
const htmlElement: HTMLElement = event.target as HTMLElement;
if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
}}
//@ts-ignore
onMouseOut={(event) => {
const htmlElement: HTMLElement = event.target as HTMLElement;
if (htmlElement.localName === "li") htmlElement.style.background = "transparent";
}}
onClick={() => (context.themeManager.theme = theme)}>
<div style={{ background: theme.themePreviewColor }} className="theme-color-circle" />
{theme.name}
<style jsx>{`
.theme-color-circle {
border-radius: 5vw;
width: 1vw;
height: 1vw;
margin-right: 1vw;
}
`}</style>
</MenuItem>
);
})
return (
<div className="menu-container">
<Menu
menuStyle={{ background: context.themeManager.theme.appBarBgColor }}
menuButton={menuButton}
transition
align="end">
{menuItems}
</Menu>
<style jsx>{`
.menu-container {
margin: 0 1vh;
display: flex;
align-items: center;
}
`}</style>
</div>
);
}
export default ThemeSwitchMenu;

View File

@ -24,13 +24,10 @@ import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat
import { getMoveText } from "../../util/MoveText";
import BoardView from "../board/BoardView";
import Button from "../Button";
import HeaderBar from "../headerbar/HeaderBar";
import HeaderbarIcon from "../headerbar/HeaderbarIcon";
import HeaderbarTitle from "../headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../headerbar/ThemeSwitchMenu";
import PageContainer from "../PageContainer";
import AppHeader from "../headerbar/AppHeader";
import { PlusIcon } from "../Icons";
import GameClockView from "../GameClockView";
import Row from "../Row";
import MoveList from "./MoveList";
const SPEEDS = [1, 2, 4];
@ -166,19 +163,12 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
);
return (
<PageContainer theme={theme}>
<HeaderBar color={theme.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}><HeaderbarIcon /></Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button context={context} color={theme.pitColor} text={t("NewGame")} onClick={() => navigate("/loby")} />
</Row>
</HeaderBar>
<PageContainer>
<AppHeader context={context}>
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={() => navigate("/loby")}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button>
</AppHeader>
<div className="result" style={{ background: theme.boardColor, color: panelTextColor }}>
<div>

View File

@ -5,6 +5,7 @@ import { GameStore, GameStoreImpl } from "../store/GameStore";
import { AuthService } from "../auth/AuthService";
import { LocalStorage } from "../storage";
import { Storage } from '@mancala/core'
import { webThemes } from "../theme/themes";
export type Context = {
rtmt: RTMT;
@ -20,7 +21,7 @@ export const initContext = (): Context => {
const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId });
const gameStore = new GameStoreImpl({ httpService });
const storage = new LocalStorage();
const themeManager = new ThemeManager(storage);
const themeManager = new ThemeManager(storage, webThemes);
return {
rtmt: rtmt,
authService,

View File

@ -1,31 +1,18 @@
<!DOCTYPE html>
<html>
<html class="t-ceviz">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Mancala</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="mancala.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,700;12..96,800&display=swap" />
</head>
<body style="margin: 0px;">
<div id="main" style="display: flex;"></div>
<body>
<div id="main"></div>
<script type="module" src="./App.tsx"></script>
</body>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,400,0,0" />
<style>
html{
height: 100%;
}
body{
height: 100%;
}
#main{
height: 100%;
}
</style>
</html>
</html>

View File

@ -2,20 +2,15 @@ import { MancalaGame, Pit } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { v4 } from 'uuid';
import { PitAnimator } from '@mancala/core';
import BoardToolbar from '../components/board/BoardToolbar';
import BoardView from '../components/board/BoardView';
import Button from '../components/Button';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import InfoPanel from '../components/InfoPanel';
import LoadingComponent from '../components/LoadingComponent';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import AppHeader from '../components/headerbar/AppHeader';
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 { Context } from '../context/context';
@ -341,7 +336,7 @@ const GamePage: FunctionComponent<{
}
return (
<PageContainer theme={theme!}>
<PageContainer>
{renderHeaderBar()}
{renderMobileBoardToolbar()}
{buildBoardTopToolbar()}
@ -371,30 +366,21 @@ const GamePage: FunctionComponent<{
collapsedByDefault={isMobile} />
)}
<Center>
<LoadingComponent context={context} loadingState={gameLoadingState}></LoadingComponent>
<LoadingComponent loadingState={gameLoadingState}></LoadingComponent>
</Center>
</PageContainer>
);
function renderHeaderBar() {
return <HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={renderNewGameBtn ? t("NewGame") : t("Leave")}
onClick={renderNewGameBtn ? onNewGameClick : onLeaveGameClick} />
</Row>
</HeaderBar>;
return <AppHeader context={context}>
{renderNewGameBtn
? <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button>
: <button className="btn ghost ic-m" aria-label={t("Leave")} onClick={onLeaveGameClick}>
<LeaveIcon /><span className="hide-m">{t("Leave")}</span>
</button>}
</AppHeader>;
}
function renderMobileBoardToolbar() {

View File

@ -1,20 +1,15 @@
import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react";
import { getColorByBrightness, LiveGameSummary } from "@mancala/core";
import { LiveGameSummary } from "@mancala/core";
import { AuthUser, Theme } from "@mancala/core";
import HeaderBar from "../components/headerbar/HeaderBar";
import AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer";
import Row from "../components/Row";
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
import AccountMenu from "../components/headerbar/AccountMenu";
import { PlusIcon } from "../components/Icons";
import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection";
import Button from "../components/Button";
import { Context } from "../context/context";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import Util from "../util/Util";
import { useTranslation } from "react-i18next";
@ -46,32 +41,13 @@ const Home: FunctionComponent<{
navigate("/loby")
};
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
<AccountMenu context={context} textColor={textColorOnAppBar} authUser={authUser} />
<Button
context={context}
color={context.themeManager.theme.pitColor}
text={t("NewGame")}
onClick={onNewGameClick} />
</Row>
</HeaderBar>
<PageContainer>
<AppHeader context={context} authUser={authUser}>
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button>
</AppHeader>
{activeGame && authUser && (
<ActiveGameBanner
context={context}

View File

@ -1,27 +1,19 @@
import { CommonMancalaGame, MancalaGame } from 'mancala.js';
import { CommonMancalaGame } from 'mancala.js';
import * as React from 'react';
import { FunctionComponent, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Center from '../components/Center';
import CircularPanel from '../components/CircularPanel';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import { useNavigate } from 'react-router-dom';
import AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core';
import { Context } from '../context/context';
import { Theme } from '@mancala/core';
import { getColorByBrightness } from "@mancala/core";
import Button from "../components/Button";
import { useTranslation } from 'react-i18next';
const LobyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
}> = ({ context, userKey, theme }) => {
}> = ({ context }) => {
let navigate = useNavigate();
@ -52,58 +44,60 @@ const LobyPage: FunctionComponent<{
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
}
}, []);
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.boardColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
</Row>
</HeaderBar>
<Center>
<div style={{
display: "flex",
flexDirection: "column",
alignContent: "center"
}}>
<CircularPanel color={context.themeManager.theme.boardColor}>
<h4 style={{ margin: "0", color: textColorOnBoard }}>
{activeGameId ? t("GameInProgress") : `${t("SearchingOpponent")} ${t("PleaseWait")}...`}
</h4>
</CircularPanel>
{activeGameId && <Button
context={context}
color={context.themeManager.theme.pitColor}
text={t("Continue")}
onClick={() => navigate(`/game/${activeGameId}`)} />}
<Button
context={context}
color={context.themeManager.theme.boardColor}
text={t("Cancel")}
onClick={onCancelNewGameClick} />
</div>
</Center>
return (
<PageContainer>
<AppHeader context={context} />
<main className="wrap">
<section className="search panel" aria-live="polite">
{!activeGameId && <span className="dot" />}
<h1>{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}</h1>
{!activeGameId && <p>{`${t("PleaseWait")}...`}</p>}
<div className="actions">
{activeGameId && (
<button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button>
)}
<button className="btn ghost" onClick={onCancelNewGameClick}>{t("Cancel")}</button>
</div>
</section>
</main>
<style jsx>{`
.search {
align-self: center;
width: 100%;
max-width: 460px;
margin-top: 8vh;
padding: 32px;
box-sizing: border-box;
border-radius: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.dot {
width: 14px;
height: 14px;
color: var(--accent);
}
h1 {
margin: 0;
font-size: 28px;
letter-spacing: -.02em;
}
p {
margin: 0;
color: var(--muted);
}
.actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
`}</style>
</PageContainer>
);
}
export default LobyPage;
export default LobyPage;

View File

@ -1,56 +1,19 @@
import * as React from 'react';
import { FunctionComponent, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import HeaderBar from '../components/headerbar/HeaderBar';
import HeaderbarIcon from '../components/headerbar/HeaderbarIcon';
import HeaderbarTitle from '../components/headerbar/HeaderbarTitle';
import ThemeSwitchMenu from '../components/headerbar/ThemeSwitchMenu';
import { FunctionComponent } from 'react';
import AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer';
import Row from '../components/Row';
import { Context } from '../context/context';
import { Theme } from '@mancala/core';
import { getColorByBrightness } from "@mancala/core";
import { useTranslation } from 'react-i18next';
const PrivacyPage: FunctionComponent<{
context: Context,
userKey?: string,
theme: Theme
}> = ({ context, userKey, theme }) => {
const { t } = useTranslation();
const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
const textColorOnBoard = getColorByBrightness(
context.themeManager.theme.background,
"#000",
"#aaa"
);
}> = ({ context }) => {
return (
<PageContainer theme={theme!}>
<HeaderBar color={theme?.appBarBgColor}>
<Row>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarIcon />
</Link>
<Link style={{ textDecoration: 'none' }} to={"/"}>
<HeaderbarTitle title={t("Mancala")} color={textColorOnAppBar} />
</Link>
</Row>
<Row>
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
</Row>
</HeaderBar>
<div style={{
margin: '30px',
overflowY: 'scroll',
color: textColorOnBoard
}}>
<PageContainer>
<AppHeader context={context} />
<main className="wrap privacy">
<h2>Privacy Policy</h2>
<p> The Mancala team and volunteers built the Mancala app as a open source app. This SERVICE is provided by mancala team and volunteers at no cost and is intended
for use as is.</p>
@ -82,9 +45,22 @@ const PrivacyPage: FunctionComponent<{
<p><strong>Contact Us</strong></p>
<p>If you have any questions or suggestions about our Privacy Policy, do not hesitate to <a href='/contact'>contact
us</a>. </p>
</div>
</main>
<style jsx>{`
.privacy {
max-width: 760px;
display: block;
line-height: 1.55;
}
.privacy :global(p) {
color: var(--muted);
}
.privacy :global(strong), .privacy :global(h2) {
color: var(--ink);
}
`}</style>
</PageContainer>
);
}
export default PrivacyPage;
export default PrivacyPage;

View File

@ -0,0 +1,244 @@
/* Themes, base styles and the building blocks shared by the pages. The theme class is set on <html>. */
.t-ceviz {
--bg: #1f1712; --surface: #2a2019; --surface2: #382a20; --line: rgba(246, 234, 216, .10);
--ink: #f6ead8; --muted: #c4ad93; --accent: #f2b84b; --onAccent: #2a1a0a;
--accentSoft: rgba(242, 184, 75, .45); --accentGlow: rgba(242, 184, 75, .35); --lastRing: rgba(246, 234, 216, .55);
--chip: #1a110b; --chipInk: #f6ead8; --rim: #8a5d3b; --pit: #2c1d13; --live: #ff8a73;
--miniBg: #5b3d28; --miniPit: #2c1d13;
--boardBg: repeating-linear-gradient(94deg, rgba(255, 255, 255, .025) 0 3px, rgba(0, 0, 0, .04) 3px 9px, rgba(255, 255, 255, .015) 9px 14px), linear-gradient(180deg, #6e4a30, #573822);
--boardShadow: inset 0 1px 0 rgba(255, 255, 255, .12), inset 0 -3px 0 rgba(0, 0, 0, .25), 0 26px 50px rgba(0, 0, 0, .45), 0 0 0 1px #3a2516;
--pitShadow: inset 0 8px 16px rgba(0, 0, 0, .6), inset 0 -2px 0 rgba(255, 255, 255, .07), 0 1px 0 rgba(255, 255, 255, .08);
--stoneShadow: 0 3px 4px rgba(0, 0, 0, .45);
color-scheme: dark;
}
.t-gece {
--bg: #0c1220; --surface: #141c30; --surface2: #1d2744; --line: rgba(238, 241, 248, .09);
--ink: #eef1f8; --muted: #9aa6c2; --accent: #e2b36a; --onAccent: #241707;
--accentSoft: rgba(226, 179, 106, .45); --accentGlow: rgba(226, 179, 106, .35); --lastRing: rgba(238, 241, 248, .5);
--chip: #0a0f1c; --chipInk: #eef1f8; --rim: #b8925a; --pit: #0b1020; --live: #ff8a7a;
--miniBg: #1d2a4d; --miniPit: #0b1020;
--boardBg: linear-gradient(180deg, #1d2a4d, #141d38);
--boardShadow: inset 0 0 0 2px #b8925a, inset 0 0 0 7px #18223f, inset 0 0 0 8px rgba(184, 146, 90, .35), 0 26px 50px rgba(0, 0, 0, .5);
--pitShadow: inset 0 8px 18px rgba(0, 0, 0, .7), inset 0 0 0 1px rgba(184, 146, 90, .28), 0 1px 0 rgba(255, 255, 255, .06);
--stoneShadow: 0 3px 5px rgba(0, 0, 0, .55);
color-scheme: dark;
}
.t-yesim {
--bg: #e9ebe3; --surface: #f7f8f3; --surface2: #dde0d3; --line: rgba(29, 39, 33, .12);
--ink: #1d2721; --muted: #4f5d55; --accent: #2a7650; --onAccent: #ffffff;
--accentSoft: rgba(42, 118, 80, .4); --accentGlow: rgba(42, 118, 80, .28); --lastRing: rgba(29, 39, 33, .5);
--chip: #1d2721; --chipInk: #ffffff; --rim: #e9ebe3; --pit: #c3c7b6; --live: #b43a26;
--miniBg: #d3d6c8; --miniPit: #bcc0ae;
--boardBg: linear-gradient(180deg, #dcdfd2, #cfd2c3);
--boardShadow: inset 0 2px 0 rgba(255, 255, 255, .75), inset 0 -3px 0 rgba(0, 0, 0, .08), 0 22px 44px rgba(40, 50, 40, .2), 0 0 0 1px #bcc0ae;
--pitShadow: inset 0 7px 14px rgba(40, 50, 40, .38), inset 0 -2px 0 rgba(255, 255, 255, .55), 0 1px 0 rgba(255, 255, 255, .75);
--stoneShadow: 0 3px 4px rgba(30, 40, 30, .35);
color-scheme: light;
}
html, body, #main { min-height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
transition: background .3s, color .3s;
}
#main { display: flex; flex-direction: column; min-height: 100vh; }
a { color: inherit; }
/* Page */
.page { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.wrap {
width: 100%;
max-width: 1180px;
margin: 0 auto;
padding: 28px 32px 56px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 26px;
}
/* Header */
.hdr {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 32px;
background: var(--bg);
border-bottom: 1px solid var(--line);
transition: background .3s;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
margin-right: auto;
min-width: 0;
color: var(--ink);
text-decoration: none;
font-weight: 800;
font-size: 24px;
letter-spacing: -.02em;
}
.logo svg { flex: none; }
.logo span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.themes { display: flex; gap: 2px; padding: 3px; border-radius: 26px; background: var(--surface); }
.sw {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
padding: 0;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.sw span { width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); }
.sw.on { background: var(--surface2); box-shadow: inset 0 0 0 2px var(--accent); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border-radius: 22px;
border: none;
font: inherit;
font-weight: 700;
font-size: 15px;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
box-sizing: border-box;
}
.btn.pri { background: var(--accent); color: var(--onAccent); }
.btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
.btn.icon { width: 44px; padding: 0; }
.btn:disabled { opacity: .4; cursor: default; }
.sw:focus-visible, .btn:focus-visible, .card:focus-visible, .logo:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
/* Pills, cards */
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border-radius: 13px;
font-size: 12px;
font-weight: 700;
background: var(--surface2);
color: var(--ink);
white-space: nowrap;
}
.pill.live, .pill.lose { color: var(--live); }
.pill.live::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
animation: kpulse 1.6s ease-in-out infinite;
}
.pill.win { background: var(--accent); color: var(--onAccent); }
.panel { border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
.dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; animation: kpulse 1.6s ease-in-out infinite; }
@keyframes kpulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .35; transform: scale(.7); }
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Account menu (@szhsin/react-menu) */
.szh-menu {
background: var(--surface);
color: var(--ink);
border-radius: 16px;
padding: 6px;
box-shadow: inset 0 0 0 1px var(--line), 0 16px 30px rgba(0, 0, 0, .25);
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
}
.szh-menu__header { color: var(--muted); font-weight: 700; text-transform: none; font-size: 13px; }
.szh-menu__item { border-radius: 10px; min-height: 40px; font-weight: 600; color: var(--ink); }
.szh-menu__item--hover { background: var(--surface2); }
/* Dialogs (sweetalert) */
.swal-modal { background: var(--surface); border-radius: 24px; font-family: 'Bricolage Grotesque', system-ui, sans-serif; }
.swal-title, .swal-text { color: var(--ink); }
.swal-button { border-radius: 22px; font-family: inherit; }
@media (max-width: 640px) {
.hdr { padding: 10px 16px; gap: 8px; }
.logo { font-size: 20px; gap: 8px; }
.logo svg { width: 28px; height: 28px; }
.themes .sw { width: 40px; height: 40px; }
.hide-m { display: none !important; }
.ic-m { width: 44px; padding: 0; }
.wrap { padding: 16px 16px 32px; gap: 16px; }
}
/* The account button makes the header too wide for the name on phones. */
@media (max-width: 420px) {
.hdr.has-account .logo span { display: none; }
}
@media (max-width: 360px) {
.logo span { display: none; }
}

View File

@ -0,0 +1,94 @@
import { Theme } from "@mancala/core";
// The web app's themes. Pages are coloured by the CSS variables of the theme's class (styles/app.css); the colours
// here are for code that still reads core's Theme, such as PitAnimator.
export type WebTheme = Theme & {
// CSS class on <html>.
className: string;
// Translation key of the theme's name.
labelKey: string;
// Colours of the theme's swatch.
swatch: { board: string, accent: string };
};
const walnut: WebTheme = {
id: "ceviz",
name: "Ceviz",
className: "t-ceviz",
labelKey: "ThemeWalnut",
swatch: { board: "#6e4a30", accent: "#f2b84b" },
themePreviewColor: "#6e4a30",
background: "#1f1712",
appBarBgColor: "#2a2019",
textColor: "#2a1a0a",
textLightColor: "#f6ead8",
playerTurnColor: "#f2b84b",
boardColor: "#6e4a30",
pitColor: "#2c1d13",
pitSelectedColor: "#f2b84b",
stoneColor: "#e39a32",
stoneLightColor: "#fff1c9",
pitGameMoveAnimateColor: "#f2b84b",
pitEmptyPitAnimateColor: "#ff8a73",
pitLastStoneInBankPitAnimateColor: "#f2b84b",
pitGetRivalStonePitAnimateColor: "#ff8a73",
};
const night: WebTheme = {
id: "gece",
name: "Gece",
className: "t-gece",
labelKey: "ThemeNight",
swatch: { board: "#1d2a4d", accent: "#e2b36a" },
themePreviewColor: "#1d2a4d",
background: "#0c1220",
appBarBgColor: "#141c30",
textColor: "#241707",
textLightColor: "#eef1f8",
playerTurnColor: "#e2b36a",
boardColor: "#1d2a4d",
pitColor: "#0b1020",
pitSelectedColor: "#e2b36a",
stoneColor: "#dfe3ee",
stoneLightColor: "#ffffff",
pitGameMoveAnimateColor: "#e2b36a",
pitEmptyPitAnimateColor: "#ff8a7a",
pitLastStoneInBankPitAnimateColor: "#e2b36a",
pitGetRivalStonePitAnimateColor: "#ff8a7a",
};
const jade: WebTheme = {
id: "yesim",
name: "Yeşim",
className: "t-yesim",
labelKey: "ThemeJade",
swatch: { board: "#cfd2c3", accent: "#2a7650" },
themePreviewColor: "#cfd2c3",
background: "#e9ebe3",
appBarBgColor: "#f7f8f3",
textColor: "#1d2721",
textLightColor: "#ffffff",
playerTurnColor: "#2a7650",
boardColor: "#cfd2c3",
pitColor: "#c3c7b6",
pitSelectedColor: "#2a7650",
stoneColor: "#3f9f6f",
stoneLightColor: "#e2ffee",
pitGameMoveAnimateColor: "#2a7650",
pitEmptyPitAnimateColor: "#b43a26",
pitLastStoneInBankPitAnimateColor: "#2a7650",
pitGetRivalStonePitAnimateColor: "#b43a26",
};
// The first one is the default.
export const webThemes: WebTheme[] = [walnut, night, jade];
export function getWebTheme(theme: Theme): WebTheme {
return webThemes.find((webTheme) => webTheme.id === theme.id) ?? walnut;
}
export function applyThemeClass(theme: Theme) {
const root = document.documentElement;
webThemes.forEach((webTheme) => root.classList.remove(webTheme.className));
root.classList.add(getWebTheme(theme).className);
}

View File

@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [x] 1. **Core**: `ThemeManager` takes its themes; `PitViewModel.highlight` set by `PitAnimator`; `getSowingPath`
with unit tests; new texts (en, tr).
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
- [ ] 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.
- [ ] 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.