feat(frontend): add the new themes and header
Ceviz, Gece and Yeşim themes as CSS variables on <html>, Bricolage Grotesque, shared styles, and one header for every page with theme swatches, the account menu and the page's main action. The lobby, privacy page and status panels use the new look.
This commit is contained in:
parent
e0c66a1265
commit
6279b5a820
@ -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) => {
|
||||
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>
|
||||
)
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
const text = loadingState.isLoading() ? t("Loading") +"..." : loadingState.errorMessage;
|
||||
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