Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,30 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Context } from "../context/context";
|
||||
|
||||
const FloatingPanel: FunctionComponent<{
|
||||
context: Context;
|
||||
color: string;
|
||||
visible: boolean;
|
||||
}> = (props) => {
|
||||
// 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>
|
||||
)
|
||||
};
|
||||
|
||||
69
apps/frontend/src/components/Icons.tsx
Normal file
69
apps/frontend/src/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
@ -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
|
||||
);
|
||||
if (!loadingState.isLoading() && !loadingState.isError()) return <></>;
|
||||
const text = loadingState.isLoading() ? t("Loading") + "..." : loadingState.errorMessage;
|
||||
return (
|
||||
<CircularPanel color={context.themeManager.theme.boardColor}>
|
||||
<h4 style={{ margin: "0", color: textColorOnBoard }}>{`${text}`}</h4>
|
||||
</CircularPanel>
|
||||
);
|
||||
<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;
|
||||
}
|
||||
return <></>
|
||||
.dot {
|
||||
color: var(--accent);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingComponent;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
|
||||
<MenuHeader>{getPlayerDisplayName(authUser, t)}</MenuHeader>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
<style jsx>{`
|
||||
.menu-container {
|
||||
margin: 0 1vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
28
apps/frontend/src/components/headerbar/AppHeader.tsx
Normal file
28
apps/frontend/src/components/headerbar/AppHeader.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
26
apps/frontend/src/components/headerbar/ThemeSwatches.tsx
Normal file
26
apps/frontend/src/components/headerbar/ThemeSwatches.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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() {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -53,55 +45,57 @@ const LobyPage: FunctionComponent<{
|
||||
}
|
||||
}, []);
|
||||
|
||||
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} />
|
||||
<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>
|
||||
|
||||
</Center>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,7 +45,20 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
244
apps/frontend/src/styles/app.css
Normal file
244
apps/frontend/src/styles/app.css
Normal 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; }
|
||||
}
|
||||
94
apps/frontend/src/theme/themes.ts
Normal file
94
apps/frontend/src/theme/themes.ts
Normal 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);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user