Webatrice P.O.C. (#3854)

* port webclient POC into react shell

* Abstract websocket messaging behind redux store

* refactor architecture

* add rooms store

* introduce application service layer and login form

* display room messages

* implement roomSay

* improve Room view styling

* display room games

* improve gameList update logic

* hide protected games

* improve game update logic

* move mapping to earlier lifecycle hook

* add autoscroll to bottom

* tabs to spaces, refresh guard

* implement server joins/leaves

* show users in room

* add material-ui to build

* refactor, add room joins/leaves to store and render

* begin using Material UI components

* fix spectatorsCount

* remove unused package

* improve Server and Room styling

* fix scroll context

* route on room join

* refactor room path

* add auth guard

* refactor authGuard export

* add missing files

* clear store on disconnect, add logout button to Account view

* fix disconnect handling

* Safari fixes

* organize current todos

* improve login page and server status tracking

* improve login page

* introduce sorting arch, refine reducers, begin viewLogHistory

* audit fix for handlebars

* implement moderator log view

* comply with code style rules

* remove original POC from codebase

* add missing semi

* minor improvements, begin registration functionality

* retry as ws when wss fails

additionally, dont mutate the default options when connecting

* retain user/pass in WebClient.options for login

* take protocol off of options, make it a connect param that defaults to wss

* cleanup server page styling

* match wss logic with desktop client

* add virtual scroll component, add context menu to UserDisplay

* revert VirtualTable on messages

* improve styling for Room view

* add routing to Player view

* increase tooltip delay

* begin implementing Account view

* disable app level contextMenu

* implement buddy/ignore list management

* fix gitignore

Co-authored-by: Jay Letto <jeremy.letto@merrillcorp.com>
Co-authored-by: skwerlman <skwerlman@users.noreply.github.com>
Co-authored-by: Jeremy Letto <jeremy.letto@datasite.com>
This commit is contained in:
Jeremy Letto 2020-12-31 16:08:15 -06:00 committed by GitHub
parent d5b36e8b8a
commit 0457e65751
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
152 changed files with 19573 additions and 1071 deletions

View file

@ -0,0 +1,49 @@
.account {
display: flex;
justify-content: space-between;
height: 100%;
padding: 5px;
}
.account-column {
display: flex;
flex-direction: column;
width: 33%;
}
.account-list {
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
}
.account-details {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.account-details__actions {
display: flex;
align-items: stretch;
justify-content: space-around;
width: 100%;
}
.account-details p {
margin-bottom: 10px;
}
.account-details button {
margin-top: 10px;
font-size: 10px;
}
.account-details img {
width: 100%;
margin-bottom: 20px;
}

View file

@ -0,0 +1,122 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import Button from "@material-ui/core/Button";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import { AuthenticationService, SessionService } from "AppShell/common/services";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import { Selectors } from "store/server";
import { User } from 'types';
import AddToBuddies from './AddToBuddies/AddToBuddies';
import AddToIgnore from './AddToIgnore/AddToIgnore';
import "./Account.css";
class Account extends Component<AccountProps> {
handleAddToBuddies({ userName }) {
SessionService.addToBuddyList(userName);
}
handleAddToIgnore({ userName }) {
SessionService.addToIgnoreList(userName);
}
render() {
console.log(this.props);
const { buddyList, ignoreList, serverName, serverVersion, user } = this.props;
const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user;
var url = URL.createObjectURL(new Blob([avatarBmp], {'type': 'image/png'}));
return (
<div className="account">
<AuthGuard />
<div className="account-column">
<Paper className="account-list">
<div className="">
Buddies Online: ?/{buddyList.length}
</div>
<VirtualList
itemKey={(index, data) => buddyList[index].name }
items={ buddyList.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
<div className="" style={{borderTop: "1px solid"}}>
<AddToBuddies onSubmit={this.handleAddToBuddies} />
</div>
</Paper>
</div>
<div className="account-column">
<Paper className="account-list overflow-scroll">
<div className="">
Ignored Users Online: ?/{ignoreList.length}
</div>
<VirtualList
itemKey={(index, data) => ignoreList[index].name }
items={ ignoreList.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
<div className="" style={{borderTop: "1px solid"}}>
<AddToIgnore onSubmit={this.handleAddToIgnore} />
</div>
</Paper>
</div>
<div className="account-column overflow-scroll">
<Paper className="account-details" style={{margin: "0 0 5px 0"}}>
<img src={url} alt={name} />
<p><strong>{name}</strong></p>
<p>Location: ({country.toUpperCase()})</p>
<p>User Level: {userLevel}</p>
<p>Account Age: {accountageSecs}</p>
<p>Real Name: {realName}</p>
<div className="account-details__actions">
<Button size="small" color="primary" variant="contained">Edit</Button>
<Button size="small" color="primary" variant="contained">Change<br />Password</Button>
<Button size="small" color="primary" variant="contained">Change<br />Avatar</Button>
</div>
</Paper>
<Paper className="account-details">
<p>Server Name: {serverName}</p>
<p>Server Version: {serverVersion}</p>
<Button color="primary" variant="contained" onClick={() => AuthenticationService.disconnect()}>Disconnect</Button>
</Paper>
</div>
</div>
)
}
}
interface AccountProps {
buddyList: User[];
ignoreList: User[];
serverName: string;
serverVersion: string;
user: User;
}
const mapStateToProps = state => ({
buddyList: Selectors.getBuddyList(state),
ignoreList: Selectors.getIgnoreList(state),
serverName: Selectors.getName(state),
serverVersion: Selectors.getVersion(state),
user: Selectors.getUser(state),
});
export default connect(mapStateToProps)(Account);

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const AddToBuddies = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Buddies" name="userName" />
</Form>
);
const propsMap = {
form: "addToBuddies"
};
export default connect()(reduxForm(propsMap)(AddToBuddies));

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const AddToIgnore = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Ignore" name="userName" />
</Form>
);
const propsMap = {
form: "addToIgnore"
};
export default connect()(reduxForm(propsMap)(AddToIgnore));

View file

@ -0,0 +1,10 @@
.AppShell,
.AppShell-routes {
height: 100%;
}
.AppShell {
display: flex;
flex-direction: column;
min-width: 768px;
}

View file

@ -0,0 +1,39 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { Provider } from "react-redux";
import { MemoryRouter as Router } from "react-router-dom";
import CssBaseline from "@material-ui/core/CssBaseline";
import { store } from "store";
import "./AppShell.css";
import Routes from "./AppShellRoutes";
import Header from "./Header/Header";
class AppShell extends Component {
componentDidMount() {
// @TODO (1)
window.onbeforeunload = () => true;
}
handleContextMenu(event) {
event.preventDefault();
}
render() {
return (
<Provider store={store}>
<CssBaseline />
<div className="AppShell" onContextMenu={this.handleContextMenu}>
<Router>
<Header />
<Routes />
</Router>
</div>
</Provider>
);
}
}
export default AppShell;

View file

@ -0,0 +1,29 @@
import React from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { RouteEnum } from "./common/types";
import Account from "./Account/Account";
import Decks from "./Decks/Decks";
import Game from "./Game/Game";
import Logs from "./Logs/Logs";
import Player from "./Player/Player";
import Room from "./Room/Room";
import Server from "./Server/Server";
const Routes = () => (
<div className="AppShell-routes overflow-scroll">
<Switch>
<Route path={RouteEnum.ACCOUNT} render={() => <Account />} />
<Route path={RouteEnum.DECKS} render={() => <Decks />} />
<Route path={RouteEnum.GAME} render={() => <Game />} />
<Route path={RouteEnum.LOGS} render={() => <Logs />} />
<Route path={RouteEnum.PLAYER} render={() => <Player />} />
{<Route path={RouteEnum.ROOM} render={() => <Room />} />}
<Route path={RouteEnum.SERVER} render={() => <Server />} />
<Redirect from="/" to={RouteEnum.SERVER} />
</Switch>
</div>
);
export default Routes;

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Decks.css";
class Decks extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Decks"</span>
</div>
)
}
}
export default Decks;

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Game.css";
class Game extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Game"</span>
</div>
)
}
}
export default Game;

View file

@ -0,0 +1,77 @@
.Header {
}
.Header__logo {
display: flex;
}
.Header__logo img {
height: 40px;
}
.Header-content {
display: flex;
align-items: center;
width: 100%;
padding: 5px;
color: white;
}
.Header-serverDetails {
font-size: 12px;
}
.Header-nav {
width: 100%;
display: flex;
align-items: center;
margin-right: 10px;
}
.Header-nav__items {
width: 100%;
display: flex;
justify-content: flex-end;
}
.Header-nav__item {
list-style: none;
margin: 0 10px;
}
.Header-account {
display: flex;
align-items: center;
}
.Header-account__name {
margin-right: 10px;
font-weight: bold;
}
.Header-account__indicator {
display: inline-block;
height: 16px;
width: 16px;
background: red;
border: 2px solid;
border-radius: 50%;
}
.temp-subnav__rooms {
display: flex;
align-items: center;
font-size: 10px;
padding: 5px;
}
.temp-chip {
margin-left: 5px;
text-decoration: none;
}
.temp-chip > div {
cursor: inherit;
}

View file

@ -0,0 +1,119 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { NavLink, withRouter, generatePath } from "react-router-dom";
import AppBar from "@material-ui/core/AppBar";
import Chip from "@material-ui/core/Chip";
import Toolbar from "@material-ui/core/Toolbar";
import * as _ from "lodash";
import { Selectors as RoomsSelectors } from "store/rooms";
import { Selectors as ServerSelectors } from "store/server";
import { Room, User } from "types";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import "./Header.css";
import logo from "./logo.png";
class Header extends Component<HeaderProps> {
componentDidUpdate(prevProps) {
const currentRooms = this.props.joinedRooms;
const previousRooms = prevProps.joinedRooms;
if (currentRooms > previousRooms) {
const { roomId } = _.difference(currentRooms, previousRooms)[0];
this.props.history.push(generatePath(RouteEnum.ROOM, { roomId }));
}
}
render() {
const { joinedRooms, server, state, user } = this.props;
return (
<div>
{/*<header className="Header">*/}
<AppBar position="static">
<Toolbar variant="dense">
<NavLink to={RouteEnum.SERVER} className="Header__logo">
<img src={logo} alt="logo" />
</NavLink>
{ AuthenticationService.isConnected(state) && (
<div className="Header-content">
<nav className="Header-nav">
<ul className="Header-nav__items">
{
AuthenticationService.isModerator(user) && (
<li className="Header-nav__item">
<NavLink to={RouteEnum.LOGS}>
<button>Logs</button>
</NavLink>
</li>
)
}
<li className="Header-nav__item">
<NavLink to={RouteEnum.SERVER} className="plain-link">
Server ({server})
</NavLink>
</li>
<NavLink to={RouteEnum.ACCOUNT} className="plain-link">
<div className="Header-account">
<span className="Header-account__name">
{user.name}
</span>
<span className="Header-account__indicator"></span>
</div>
</NavLink>
</ul>
</nav>
</div>
) }
</Toolbar>
</AppBar>
<div className="temp-subnav">
{
!!joinedRooms.length && (
<Rooms rooms={joinedRooms} />
)
}
<div className="temp-subnav__games">
</div>
<div className="temp-subnav__chats">
</div>
</div>
</div>
)
}
}
const Rooms = props => (
<div className="temp-subnav__rooms">
<span>Rooms: </span>
{
_.reduce(props.rooms, (rooms, { name, roomId}) => {
rooms.push(
<NavLink to={generatePath(RouteEnum.ROOM, { roomId })} className="temp-chip" key={roomId}>
<Chip label={name} color="primary" />
</NavLink>
);
return rooms;
}, [])
}
</div>
)
interface HeaderProps {
state: number;
server: string;
user: User;
joinedRooms: Room[];
history: any;
}
const mapStateToProps = state => ({
state: ServerSelectors.getState(state),
server: ServerSelectors.getName(state),
user: ServerSelectors.getUser(state),
joinedRooms: RoomsSelectors.getJoinedRooms(state)
});
export default withRouter(connect(mapStateToProps)(Header));

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,3 @@
.log-results {
margin-bottom: 20px;
}

View file

@ -0,0 +1,122 @@
import React from "react";
import * as _ from "lodash";
import AppBar from "@material-ui/core/AppBar";
import Box from "@material-ui/core/Box";
import Paper from "@material-ui/core/Paper";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Tab from "@material-ui/core/Tab";
import Tabs from "@material-ui/core/Tabs";
import Typography from "@material-ui/core/Typography";
import "./LogResults.css";
const LogResults = (props) => {
const { logs } = props;
const hasRoomLogs = logs.room && logs.room.length;
const hasGameLogs = logs.game && logs.game.length;
const hasChatLogs = logs.chat && logs.chat.length;
const [value, setValue] = React.useState(0);
const handleChange = (event, newValue) => {
setValue(newValue);
};
const headerCells = [
{
label: "Time"
},
{
label: "Sender Name"
},
{
label: "Sender IP"
},
{
label: "Message"
},
{
label: "Target ID"
},
{
label: "Target Name"
}
];
return (
<div>
<AppBar position="static">
<Tabs value={value} onChange={handleChange} aria-label="simple tabs example">
<Tab label={"Rooms" + (hasRoomLogs ? ` [${logs.room.length}]` : "")} {...a11yProps(0)} />
<Tab label={"Games" + (hasGameLogs ? ` [${logs.game.length}]` : "")} {...a11yProps(1)} />
<Tab label={"Chats" + (hasChatLogs ? ` [${logs.chat.length}]` : "")} {...a11yProps(2)} />
</Tabs>
</AppBar>
<TabPanel value={value} index={0}>
<Results logs={logs.room} headerCells={headerCells} />
</TabPanel>
<TabPanel value={value} index={1}>
<Results logs={logs.game} headerCells={headerCells} />
</TabPanel>
<TabPanel value={value} index={2}>
<Results logs={logs.chat} headerCells={headerCells} />
</TabPanel>
</div>
)
};
const a11yProps = index => {
return {
id: `simple-tab-${index}`,
"aria-controls": `simple-tabpanel-${index}`,
};
};
const TabPanel = ({ children, value, index, ...other }) => {
return (
<Typography
component="div"
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
<Box>{children}</Box>
</Typography>
);
};
const Results = ({headerCells, logs}) => (
<Paper className="log-results">
<Table size="small">
<TableHead>
<TableRow>
{ _.map(headerCells, ({ label }) => (
<TableCell key={label}>{label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{ _.map(logs, ({ time, senderName, senderIp, message, targetId, targetName }, index) => (
<TableRow key={index}>
<TableCell>{time}</TableCell>
<TableCell>{senderName}</TableCell>
<TableCell>{senderIp}</TableCell>
<TableCell>{message}</TableCell>
<TableCell>{targetId}</TableCell>
<TableCell>{targetName}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
export default LogResults;

View file

@ -0,0 +1,14 @@
.moderator-logs {
height: 100%;
display: flex;
padding: 20px;
}
.moderator-logs__form {
width: 40%;
margin-right: 20px;
}
.moderator-logs__results {
width: 100%;
}

View file

@ -0,0 +1,102 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import * as _ from "lodash";
import { Dispatch, Selectors, ServerStateLogs } from "store/server"
import { ModeratorService } from "AppShell/common/services";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import ModGuard from "AppShell/common/guards/ModGuard";
import LogResults from "./LogResults/LogResults";
import SearchForm from "./SearchForm/SearchForm";
import "./Logs.css";
class Logs extends Component<LogsTypes> {
MAXIMUM_RESULTS = 1000;
constructor(props) {
super(props);
this.onSubmit = this.onSubmit.bind(this);
}
componentWillUnmount() {
Dispatch.clearLogs();
}
onSubmit(fields) {
const trimmedFields: any = this.trimFields(fields);
const { userName, ipAddress, gameName, gameId, message, logLocation } = trimmedFields;
const required = _.filter({
userName, ipAddress, gameName, gameId, message
}, field => field);
if (logLocation) {
trimmedFields.logLocation = this.flattenLogLocations(logLocation);
}
trimmedFields.maximumResults = this.MAXIMUM_RESULTS;
if (_.size(required)) {
ModeratorService.viewLogHistory(trimmedFields);
} else {
// @TODO use yet-to-be-implemented banner/alert
}
}
private trimFields(fields) {
return _.reduce(fields, (obj, field, key) => {
if (typeof field === "string") {
const trimmed = _.trim(field);
if (!!trimmed) {
obj[key] = trimmed;
}
} else {
obj[key] = field;
}
return obj;
}, {});
}
private flattenLogLocations(logLocations) {
return _.reduce(logLocations, (arr, loc, key) => {
arr.push(key);
return arr;
}, [])
}
render() {
return (
<div className="moderator-logs overflow-scroll">
<AuthGuard />
<ModGuard />
<div className="moderator-logs__form">
<SearchForm onSubmit={this.onSubmit} />
</div>
<div className="moderator-logs__results">
<LogResults logs={this.props.logs} />
</div>
</div>
)
}
}
interface LogsTypes {
logs: ServerStateLogs
}
const mapStateToProps = state => ({
logs: Selectors.getLogs(state)
});
export default withRouter(connect(mapStateToProps)(Logs));

View file

@ -0,0 +1,35 @@
.log-search {
margin-bottom: 20px;
}
hr.MuiDivider-root {
margin: 20px 0;
}
.log-search__form {
width: 100%;
padding: 20px;
}
.log-search__form-item {
display: flex;
}
.log-search__form-item.log-location {
display: flex;
justify-content: space-around;
}
.log-search__form-item.log-location .checkbox-field {
display: flex;
flex-direction: column;
}
.log-search__form-item.log-location .checkbox-field__box {
order: 1;
}
.log-search__form-submit.MuiButton-root {
display: block;
margin: 0 auto;
}

View file

@ -0,0 +1,68 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { Form, Field, InjectedFormProps, reduxForm } from "redux-form"
import Button from "@material-ui/core/Button";
import Divider from "@material-ui/core/Divider";
import Paper from "@material-ui/core/Paper";
import InputField from "AppShell/common/components/InputField/InputField";
import CheckboxField from "AppShell/common/components/CheckboxField/CheckboxField";
import "./SearchForm.css";
class SearchForm extends Component<InjectedFormProps> {
render() {
return (
<Paper className="log-search">
<Form className="log-search__form" onSubmit={this.props.handleSubmit}>
<div className="log-search__form-item">
<Field label="Username" name="userName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="IP Address" name="ipAddress" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Game Name" name="gameName" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="GameID" name="gameId" component={InputField} />
</div>
<div className="log-search__form-item">
<Field label="Message" name="message" component={InputField} />
</div>
<Divider />
<div className="log-search__form-item log-location">
<Field label="Rooms" name="logLocation.room" component={CheckboxField} />
<Field label="Games" name="logLocation.game" component={CheckboxField} />
<Field label="Chats" name="logLocation.chat" component={CheckboxField} />
</div>
<Divider />
<div className="log-search__form-item">
<span>Date Range: Coming Soon</span>
</div>
<Divider />
<div className="log-search__form-item">
<span>Maximum Results: 1000</span>
</div>
<Divider />
<Button className="log-search__form-submit" color="primary" variant="contained" type="submit">
Search Logs
</Button>
</Form>
</Paper>
)
}
}
const propsMap = {
form: "logs"
};
const mapStateToProps = () => ({
});
export default connect(mapStateToProps)(reduxForm(propsMap)(SearchForm));

View file

View file

@ -0,0 +1,19 @@
// eslint-disable-next-line
import React, { Component } from "react";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import "./Player.css";
class Player extends Component {
render() {
return (
<div>
<AuthGuard />
<span>"Player"</span>
</div>
)
}
}
export default Player;

View file

@ -0,0 +1,30 @@
.games {
}
.games-header,
.game {
display: flex;
padding: 10px;
border-bottom: 1px solid black;
}
.games-header__cell {
max-width: 200px;
}
.games-header__label,
.game__detail {
width: 10%;
flex-grow: 0;
}
.games-header__label.description,
.game__detail.description {
width: 20%;
flex-grow: 1;
}
.games-header__label.creator,
.game__detail.creator {
width: 20%;
}

View file

@ -0,0 +1,144 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import * as _ from "lodash";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableSortLabel from "@material-ui/core/TableSortLabel";
import Tooltip from "@material-ui/core/Tooltip";
// import { RoomsService } from "AppShell/common/services";
import { SortUtil } from "store/common";
import { Dispatch, Selectors } from "store/rooms";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import "./Games.css";
// @TODO run interval to update timeSinceCreated
class Games extends Component<GamesProps> {
private headerCells = [
{
label: "Age",
field: "startTime"
},
{
label: "Description",
field: "description"
},
{
label: "Creator",
field: "creatorInfo.name"
},
{
label: "Type",
field: "gameType"
},
{
label: "Restrictions",
// field: "?"
},
{
label: "Players",
// field: ["maxPlayers", "playerCount"]
},
{
label: "Spectators",
field: "spectatorsCount"
},
];
handleSort(sortByField) {
const { room: { roomId }, sortBy } = this.props;
const { field, order } = SortUtil.toggleSortBy(sortByField, sortBy);
Dispatch.sortGames(roomId, field, order);
}
private isUnavailableGame({ started, maxPlayers, playerCount }) {
return !started && playerCount < maxPlayers;
}
private isPasswordProtectedGame({ withPassword }) {
return !withPassword;
}
private isBuddiesOnlyGame({ onlyBuddies }) {
return !onlyBuddies;
}
render() {
const { room, sortBy } = this.props;
const games = room.gameList.filter(game => (
this.isUnavailableGame(game) &&
this.isPasswordProtectedGame(game) &&
this.isBuddiesOnlyGame(game)
));
return (
<div className="games">
<Table size="small">
<TableHead>
<TableRow>
{ _.map(this.headerCells, ({ label, field }) => {
const active = field === sortBy.field;
const order = sortBy.order.toLowerCase();
const sortDirection = active ? order : false;
return (
<TableCell sortDirection={sortDirection} key={label}>
{!field ? label : (
<TableSortLabel
active={active}
direction={order}
onClick={() => this.handleSort(field)}
>
{label}
</TableSortLabel>
)}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{ _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => (
<TableRow key={gameId}>
<TableCell className="games-header__cell single-line-ellipsis">{startTime}</TableCell>
<TableCell className="games-header__cell">
<Tooltip title={description} placement="bottom-start" enterDelay={500}>
<div className="single-line-ellipsis">
{description}
</div>
</Tooltip>
</TableCell>
<TableCell className="games-header__cell">
<UserDisplay user={ creatorInfo } />
</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{gameType}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">?</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{`${playerCount}/${maxPlayers}`}</TableCell>
<TableCell className="games-header__cell single-line-ellipsis">{spectatorsCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
}
interface GamesProps {
room: any;
sortBy: any;
}
const mapStateToProps = state => ({
sortBy: Selectors.getSortGamesBy(state)
});
export default connect(mapStateToProps)(Games);

View file

@ -0,0 +1,17 @@
.messages {
height: 100%;
width: 100%;
padding: 10px;
font-size: 12px;
line-height: 1.3;
}
.message {
padding: 5px 0;
margin: 2px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
}
.message:last-of-type {
border: 0;
}

View file

@ -0,0 +1,31 @@
// eslint-disable-next-line
import React from "react";
import "./Messages.css";
const Messages = ({ messages }) => (
<div className="messages">
{
messages && messages.map(({ message, messageType, timeOf, timeReceived }) => (
<div className="message" key={timeReceived}>
<div className="message__detail">{ParsedMessage(message)}</div>
</div>
) )
}
</div>
);
const ParsedMessage = (message) => {
const name = message.match("^[^:]+:");
if (name && name.length) {
message = message.slice(name[0].length, message.length);
}
return <div>
<strong>{name}</strong>
{message}
</div>
};
export default Messages;

View file

@ -0,0 +1,39 @@
.room-view,
.room-view__games,
.room-view__messages,
.room-view__messages-content,
.room-view__side {
height: 100%;
}
.room-view__messages,
.room-view__side {
display: flex;
flex-direction: column;
}
.room-view__messages-sayMessage {
width: 100%;
margin: 10px auto 2px;
}
.room-view__side-label {
position: sticky;
top: 0;
padding: 10px;
background: white;
z-index: 1;
}
.room-view__side-list,
.room-view__side-list .room-view__side-list__item {
height: 100%;
}
.room-view__side-list .room-view__side-list__item {
padding: 0;
}
.room-view__side-list .room-view__side-list__item .user-display__details {
padding: 0 10px;
}

View file

@ -0,0 +1,103 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter /*, RouteComponentProps */ } from "react-router-dom";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import { RoomsStateMessages, RoomsStateRooms, Selectors } from "store/rooms";
import AuthGuard from "AppShell/common/guards/AuthGuard";
import { RoomsService } from "AppShell/common/services";
import ScrollToBottomOnChanges from "AppShell/common/components/ScrollToBottomOnChanges/ScrollToBottomOnChanges";
import ThreePaneLayout from "AppShell/common/components/ThreePaneLayout/ThreePaneLayout";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import Games from "./Games/Games";
import Messages from "./Messages/Messages";
import SayMessage from "./SayMessage/SayMessage";
import "./Room.css";
// @TODO (3)
class Room extends Component<any> {
constructor(props) {
super(props);
this.handleRoomSay = this.handleRoomSay.bind(this);
}
handleRoomSay({ message }) {
if (message) {
const { roomId } = this.props.match.params;
RoomsService.roomSay(roomId, message);
}
}
render() {
const { match, rooms} = this.props;
const { roomId } = match.params;
const room = rooms[roomId];
const messages = this.props.messages[roomId];
const users = room.userList;
return (
<div className="room-view">
<AuthGuard />
<ThreePaneLayout
fixedHeight
top={(
<Paper className="room-view__games overflow-scroll">
<Games room={room} />
</Paper>
)}
bottom={(
<div className="room-view__messages">
<Paper className="room-view__messages-content overflow-scroll">
<ScrollToBottomOnChanges changes={messages} content={(
<Messages messages={messages} />
)} />
</Paper>
<Paper className="room-view__messages-sayMessage">
<SayMessage onSubmit={this.handleRoomSay} />
</Paper>
</div>
)}
side={(
<Paper className="room-view__side overflow-scroll">
<div className="room-view__side-label">
Users in this room: {users.length}
</div>
<VirtualList
className="room-view__side-list"
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItem>
) ) }
/>
</Paper>
)}
/>
</div>
);
}
}
interface RoomProps {
messages: RoomsStateMessages;
rooms: RoomsStateRooms;
}
const mapStateToProps = state => ({
messages: Selectors.getMessages(state),
rooms: Selectors.getRooms(state)
});
export default withRouter(connect(mapStateToProps)(Room));

View file

@ -0,0 +1,18 @@
// eslint-disable-next-line
import React from "react";
import { connect } from "react-redux";
import { Form, reduxForm } from "redux-form"
import InputAction from 'AppShell/common/components/InputAction/InputAction';
const SayMessage = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Say" label="Chat" name="message" />
</Form>
);
const propsMap = {
form: "sayMessage"
};
export default connect()(reduxForm(propsMap)(SayMessage));

View file

@ -0,0 +1,14 @@
.connectForm {
width: 100%;
padding: 20px;
}
.connectForm-item {
display: flex;
flex-direction: column;
}
.connectForm-submit.MuiButton-root {
display: block;
margin: 20px auto 0;
}

View file

@ -0,0 +1,49 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { Form, Field, InjectedFormProps, reduxForm } from "redux-form"
import Button from "@material-ui/core/Button";
import InputField from "AppShell/common/components/InputField/InputField";
import "./ConnectForm.css";
class ConnectForm extends Component<InjectedFormProps> {
render() {
return (
<Form className="connectForm" onSubmit={this.props.handleSubmit}>
<div className="connectForm-item">
<Field label="Host" name="host" component={InputField} />
</div>
<div className="connectForm-item">
<Field label="Port" name="port" component={InputField} />
</div>
<div className="connectForm-item">
<Field label="User" name="user" component={InputField} autoComplete="username" />
</div>
<div className="connectForm-item">
<Field label="Pass" name="pass" type="password" component={InputField} autoComplete="current-password" />
</div>
<Button className="connectForm-submit" color="primary" variant="contained" type="submit">
Connect
</Button>
</Form>
);
}
}
const propsMap = {
form: "connect"
};
const mapStateToProps = () => ({
initialValues: {
// host: "mtg.tetrarch.co/servatrice",
// port: "443"
host: "server.cockatrice.us",
port: "4748"
}
});
export default connect(mapStateToProps)(reduxForm(propsMap)(ConnectForm));

View file

@ -0,0 +1,14 @@
.registerForm {
width: 100%;
padding: 20px;
}
.registerForm-item {
display: flex;
flex-direction: column;
}
.registerForm-submit.MuiButton-root {
display: block;
margin: 20px auto 0;
}

View file

@ -0,0 +1,58 @@
// eslint-disable-next-line
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Form, Field, InjectedFormProps, reduxForm } from 'redux-form'
import Button from '@material-ui/core/Button';
import InputField from 'AppShell/common/components/InputField/InputField';
import './RegisterForm.css';
class RegisterForm extends Component<InjectedFormProps> {
render() {
return (
<Form className="registerForm" onSubmit={this.props.handleSubmit} autoComplete="off">
<div className="registerForm-item">
<Field label="Host" name="host" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Port" name="port" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Player Name" name="userName" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Password" name="password" type="password" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Password (again)" name="passwordConfirm" type="password" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Email" name="email" type="email" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Email (again)" name="emailConfirm" type="email" component={InputField} />
</div>
<div className="registerForm-item">
<Field label="Real Name" name="realName" component={InputField} />
</div>
<Button className="registerForm-submit" color="primary" variant="contained" type="submit">
Register
</Button>
</Form>
);
}
}
const propsMap = {
form: 'register'
};
const mapStateToProps = () => ({
initialValues: {
}
});
export default connect(mapStateToProps)(reduxForm(propsMap)(RegisterForm));

View file

@ -0,0 +1,26 @@
.rooms {
}
.rooms-header,
.room {
display: flex;
padding: 10px;
border-bottom: 1px solid black;
}
.rooms-header__label,
.room__detail {
width: 10%;
flex-grow: 0;
}
.rooms-header__label.name,
.room__detail.name {
width: 20%;
}
.rooms-header__label.description,
.room__detail.description {
width: 30%;
flex-grow: 1;
}

View file

@ -0,0 +1,61 @@
// eslint-disable-next-line
import React from "react";
import { generatePath } from "react-router-dom";
import * as _ from "lodash";
import Button from "@material-ui/core/Button";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import { RoomsService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import "./Rooms.css";
const Rooms = ({ rooms, joinedRooms, history }) => {
function onClick(roomId) {
if (_.find(joinedRooms, room => room.roomId === roomId)) {
history.push(generatePath(RouteEnum.ROOM, { roomId }));
} else {
RoomsService.joinRoom(roomId);
}
}
return (
<div className="rooms">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
<TableCell>Permissions</TableCell>
<TableCell>Players</TableCell>
<TableCell>Games</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{ _.map(rooms, ({ description, gameCount, name, permissionlevel, playerCount, roomId }) => (
<TableRow key={roomId}>
<TableCell>{name}</TableCell>
<TableCell>{description}</TableCell>
<TableCell>{permissionlevel}</TableCell>
<TableCell>{playerCount}</TableCell>
<TableCell>{gameCount}</TableCell>
<TableCell>
<Button size="small" color="primary" variant="contained" onClick={() => onClick(roomId)}>
Join
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default Rooms;

View file

@ -0,0 +1,61 @@
.server,
.server-rooms,
.server-rooms__side {
height: 100%;
}
.server {
display: flex;
flex-direction: column;
align-items: center;
}
.server .form-wrapper {
display: flex;
flex-direction: column;
}
.server-connect {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.server-connect__form,
.server-connect__description {
width: 100%;
max-width: 300px;
}
.server-connect__form {
margin: 50px 0;
}
.server-connect__description {
text-align: center;
margin: 20px 0;
padding: 20px;
}
.serverRoomWrapper {
height: 100%;
}
.serverMessage {
height: 100%;
padding: 20px;
margin-bottom: 2px;
}
.server-rooms {
width: 100%;
}
.server-rooms__side-label {
position: sticky;
top: 0;
padding: 10px;
background: white;
z-index: 1;
}

View file

@ -0,0 +1,162 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import Button from "@material-ui/core/Button";
import ListItem from "@material-ui/core/ListItem";
import Paper from "@material-ui/core/Paper";
import { Selectors as RoomsSelectors } from "store/rooms";
import { Selectors as ServerSelectors } from "store/server";
import { Room, StatusEnum, User } from "types";
import ThreePaneLayout from "AppShell/common/components/ThreePaneLayout/ThreePaneLayout";
import UserDisplay from "AppShell/common/components/UserDisplay/UserDisplay";
import VirtualList from "AppShell/common/components/VirtualList/VirtualList";
import { AuthenticationService } from "AppShell/common/services";
import ConnectForm from "./ConnectForm/ConnectForm";
import RegisterForm from "./RegisterForm/RegisterForm";
import Rooms from "./Rooms/Rooms";
import "./Server.css";
class Server extends Component<ServerProps, ServerState> {
constructor(props) {
super(props);
this.showDescription = this.showDescription.bind(this);
this.showRegisterForm = this.showRegisterForm.bind(this);
this.hideRegisterForm = this.hideRegisterForm.bind(this);
this.onRegister = this.onRegister.bind(this);
this.state = {
register: false
};
}
showDescription(state, description) {
const isDisconnected = state === StatusEnum.DISCONNECTED;
const hasDescription = description && !!description.length;
return isDisconnected && hasDescription;
}
showRegisterForm() {
this.setState({register: true});
}
hideRegisterForm() {
this.setState({register: false});
}
onRegister(fields) {
console.log("register", fields);
}
render() {
const { message, rooms, joinedRooms, history, state, description, users } = this.props;
const { register } = this.state;
const isConnected = AuthenticationService.isConnected(state);
return (
<div className="server">
{
isConnected
? ( <ServerRooms rooms={rooms} joinedRooms={joinedRooms} history={history} message={message} users={users} /> )
: (
<div className="server-connect">
<Paper className="server-connect__form">
{
register
? ( <Register connect={this.hideRegisterForm} onRegister={this.onRegister} /> )
: ( <Connect register={this.showRegisterForm} /> )
}
</Paper>
</div>
)
}
{
!isConnected && this.showDescription(state, description) && (
<Paper className="server-connect__description">
{description}
</Paper>
)
}
</div>
);
}
}
const ServerRooms = ({ rooms, joinedRooms, history, message, users}) => (
<div className="server-rooms">
<ThreePaneLayout
top={(
<Paper className="serverRoomWrapper overflow-scroll">
<Rooms rooms={rooms} joinedRooms={joinedRooms} history={history} />
</Paper>
)}
bottom={(
<Paper className="serverMessage overflow-scroll" dangerouslySetInnerHTML={{ __html: message }} />
)}
side={(
<Paper className="server-rooms__side overflow-scroll">
<div className="server-rooms__side-label">
Users connected to server: {users.length}
</div>
<VirtualList
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button dense>
<UserDisplay user={user} />
</ListItem>
) ) }
/>
</Paper>
)}
/>
</div>
);
const Connect = ({register}) => (
<div className="form-wrapper">
<ConnectForm onSubmit={AuthenticationService.connect} />
{/*{<Button variant="outlined" onClick={register}>Register</Button>}*/}
</div>
);
const Register = ({ onRegister, connect }) => (
<div className="form-wrapper">
<RegisterForm onSubmit={event => onRegister(event)} />
<Button variant="outlined" onClick={connect}>Connect</Button>
</div>
);
interface ServerProps {
message: string;
state: number;
description: string;
rooms: Room[];
joinedRooms: Room[];
users: User[];
history: any;
}
interface ServerState {
register: boolean;
}
const mapStateToProps = state => ({
message: ServerSelectors.getMessage(state),
state: ServerSelectors.getState(state),
description: ServerSelectors.getDescription(state),
rooms: RoomsSelectors.getRooms(state),
joinedRooms: RoomsSelectors.getJoinedRooms(state),
users: ServerSelectors.getUsers(state)
});
export default withRouter(connect(mapStateToProps)(Server));

View file

@ -0,0 +1,25 @@
import React from "react";
import Checkbox from "@material-ui/core/Checkbox";
import FormControlLabel from "@material-ui/core/FormControlLabel";
const CheckboxField = ({ input, label }) => {
const { value, onChange } = input;
// @TODO this isnt unchecking properly
return (
<FormControlLabel
className="checkbox-field"
label={label}
control={
<Checkbox
className="checkbox-field__box"
checked={!!value}
onChange={onChange}
color="primary"
/>
}
/>
);
};
export default CheckboxField;

View file

@ -0,0 +1,19 @@
.input-action {
display: flex;
width: 100%;
align-items: center;
}
.input-action,
.input-action__item,
.input-action__submit {
padding: 5px;
}
.input-action__item {
width: 100%;
height: 100%;
}
.input-action__item > div {
margin: 0;
}

View file

@ -0,0 +1,23 @@
// eslint-disable-next-line
import React from "react";
import { Field } from "redux-form"
import Button from "@material-ui/core/Button";
import InputField from '../InputField/InputField';
import "./InputAction.css";
const InputAction = ({ action, label, name }) => (
<div className="input-action">
<div className="input-action__item">
<Field label={label} name={name} component={InputField} />
</div>
<div className="input-action__submit">
<Button color="primary" variant="contained" type="submit">
{action}
</Button>
</div>
</div>
);
export default InputAction;

View file

@ -0,0 +1,17 @@
import React from "react";
import TextField from "@material-ui/core/TextField";
const InputField = ({ input, label, name, autoComplete, type }) => (
<TextField
variant="outlined"
margin="dense"
fullWidth={true}
label={label}
name={name}
type={type}
autoComplete={autoComplete}
{ ...input }
/>
);
export default InputField;

View file

@ -0,0 +1,25 @@
import React, { useEffect, useRef } from "react";
const ScrollToBottomOnChanges = ({ content, changes }) => {
const messagesEndRef = useRef(null);
// @TODO (2)
const scrollToBottom = () => {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
useEffect(scrollToBottom, [changes]);
const styling = {
height: '100%'
};
return (
<div style={styling}>
{content}
<div ref={messagesEndRef} />
</div>
)
}
export default ScrollToBottomOnChanges;

View file

@ -0,0 +1,4 @@
.select-field label {
background: white;
padding: 0 5px;
}

View file

@ -0,0 +1,30 @@
import React from "react";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import MenuItem from "@material-ui/core/MenuItem";
import Select from "@material-ui/core/Select";
import './SelectField.css';
const SelectField = ({ input, label, options, value }) => {
const id = label + "-select-field";
const labelId = id + "-label";
return (
<FormControl variant="outlined" margin="dense" className="select-field">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
id={id}
value={value}
{ ...input }
>{
options.map((option, index) => (
<MenuItem value={index} key={index}> { option } </MenuItem>
))
}</Select>
</FormControl>
);
};
export default SelectField;

View file

@ -0,0 +1,33 @@
.three-pane-layout,
.three-pane-layout .grid {
width: 100%;
height: 100%;
margin: 0;
}
.three-pane-layout .grid-main,
.three-pane-layout .grid-side {
height: 100%;
}
.three-pane-layout .grid-main {
display: flex;
flex-direction: column;
}
.three-pane-layout .grid-main__top {
max-height: 50%;
width: 100%;
padding-bottom: 20px;
flex-shrink: 0;
}
.three-pane-layout .grid-main__top.fixedHeight {
height: 50%;
}
.three-pane-layout .grid-main__bottom {
height: 100%;
width: 100%;
flex-shrink: 1;
}

View file

@ -0,0 +1,47 @@
// eslint-disable-next-line
import React, { Component, CElement } from "react";
import { connect } from "react-redux";
import Grid from "@material-ui/core/Grid";
import Hidden from "@material-ui/core/Hidden";
import "./ThreePaneLayout.css";
class ThreePaneLayout extends Component<ThreePaneLayoutProps> {
render() {
return (
<div className="three-pane-layout">
<Grid container spacing={2} className="grid">
<Grid item xs={12} md={9} lg={10} className="grid-main">
<Grid item className={
"grid-main__top"
+ (this.props.fixedHeight ? " fixedHeight" : "")
}>
{this.props.top}
</Grid>
<Grid item className="grid-main__bottom">
{this.props.bottom}
</Grid>
</Grid>
<Hidden smDown>
<Grid item md={3} lg={2} className="grid-side">
{this.props.side}
</Grid>
</Hidden>
</Grid>
</div>
);
}
}
interface ThreePaneLayoutProps {
top: CElement<any, any>,
bottom: CElement<any, any>,
side?: CElement<any, any>,
fixedHeight?: boolean,
}
const mapStateToProps = state => ({
});
export default connect(mapStateToProps)(ThreePaneLayout);

View file

@ -0,0 +1,11 @@
.user-display,
.user-display__link {
height: 100%;
width: 100%;
}
.user-display__details {
height: 100%;
display: flex;
align-items: center;
}

View file

@ -0,0 +1,153 @@
// eslint-disable-next-line
import React, { Component } from "react";
import { connect } from "react-redux";
import { NavLink, generatePath } from "react-router-dom";
import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem";
import { SessionService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
import { Selectors } from "store/server";
import { User } from "types";
import "./UserDisplay.css";
class UserDisplay extends Component<UserDisplayProps, UserDisplayState> {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleClose = this.handleClose.bind(this);
this.navigateToUserProfile = this.navigateToUserProfile.bind(this);
this.addToBuddyList = this.addToBuddyList.bind(this);
this.removeFromBuddyList = this.removeFromBuddyList.bind(this);
this.addToIgnoreList = this.addToIgnoreList.bind(this);
this.removeFromIgnoreList = this.removeFromIgnoreList.bind(this);
this.isABuddy = this.isABuddy.bind(this);
this.isIgnored = this.isIgnored.bind(this);
this.state = {
position: null
};
}
handleClick(event) {
event.preventDefault();
this.setState({
position: {
x: event.clientX + 2,
y: event.clientY + 4,
}
});
}
handleClose() {
this.setState({
position: null
});
}
navigateToUserProfile() {
this.handleClose();
}
addToBuddyList() {
SessionService.addToBuddyList(this.props.user.name);
this.handleClose();
}
removeFromBuddyList() {
SessionService.removeFromBuddyList(this.props.user.name);
this.handleClose();
}
addToIgnoreList() {
SessionService.addToIgnoreList(this.props.user.name);
this.handleClose();
}
removeFromIgnoreList() {
SessionService.removeFromIgnoreList(this.props.user.name);
this.handleClose();
}
isABuddy() {
return this.props.buddyList.filter(user => user.name === this.props.user.name).length;
}
isIgnored() {
return this.props.ignoreList.filter(user => user.name === this.props.user.name).length;
}
render() {
const { user } = this.props;
const { position } = this.state;
const { name } = user;
const isABuddy = this.isABuddy();
const isIgnored = this.isIgnored();
console.log('user', name, !!isABuddy, !!isIgnored);
return (
<div className="user-display">
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="plain-link">
<div className="user-display__details" onContextMenu={this.handleClick}>
<div className="user-display__country"></div>
<div className="user-display__name single-line-ellipsis">{name}</div>
</div>
</NavLink>
<div className="user-display__menu">
<Menu
open={Boolean(position)}
onClose={this.handleClose}
anchorReference='anchorPosition'
anchorPosition={
position !== null
? { top: position.y, left: position.x }
: undefined
}
>
<NavLink to={generatePath(RouteEnum.PLAYER, { name })} className="user-display__link plain-link">
<MenuItem dense>Chat</MenuItem>
</NavLink>
{
!isABuddy
? ( <MenuItem dense onClick={this.addToBuddyList}>Add to Buddy List</MenuItem> )
: ( <MenuItem dense onClick={this.removeFromBuddyList}>Remove From Buddy List</MenuItem> )
}
{
!isIgnored
? ( <MenuItem dense onClick={this.addToIgnoreList}>Add to Ignore List</MenuItem> )
: ( <MenuItem dense onClick={this.removeFromIgnoreList}>Remove From Ignore List</MenuItem> )
}
</Menu>
</div>
</div>
);
}
}
interface UserDisplayProps {
user: User;
buddyList: User[];
ignoreList: User[];
}
interface UserDisplayState {
position: any;
}
const mapStateToProps = (state) => ({
buddyList: Selectors.getBuddyList(state),
ignoreList: Selectors.getIgnoreList(state)
});
export default connect(mapStateToProps)(UserDisplay);

View file

@ -0,0 +1,3 @@
.virtual-list {
height: 100%;
}

View file

@ -0,0 +1,35 @@
// eslint-disable-next-line
import React from "react";
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import './VirtualList.css';
const VirtualList = ({ items, itemKey, className = {}, size = 30 }) => (
<div className="virtual-list">
<AutoSizer>
{({ height, width }) => (
<List
className={`virtual-list__list ${className}`}
height={height}
width={width}
itemData={items}
itemCount={items.length}
itemSize={size}
itemKey={itemKey}
>
{Row}
</List>
)}
</AutoSizer>
</div>
);
const Row = ({ data, index, style }) => (
<div style={style}>
{data[index]}
</div>
);
export default VirtualList;

View file

@ -0,0 +1,26 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Redirect } from "react-router-dom";
import { Selectors } from "store/server";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
class AuthGuard extends Component<AuthGuardProps> {
render() {
return !AuthenticationService.isConnected(this.props.state)
? <Redirect from="*" to={RouteEnum.SERVER} />
: "";
}
};
interface AuthGuardProps {
state: number;
}
const mapStateToProps = state => ({
state: Selectors.getState(state),
});
export default connect(mapStateToProps)(AuthGuard);

View file

@ -0,0 +1,27 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import { Redirect } from "react-router-dom";
import { Selectors } from "store/server";
import { User } from "types";
import { AuthenticationService } from "AppShell/common/services";
import { RouteEnum } from "AppShell/common/types";
class ModGuard extends Component<ModGuardProps> {
render() {
return !AuthenticationService.isModerator(this.props.user)
? <Redirect from="*" to={RouteEnum.SERVER} />
: "";
}
};
interface ModGuardProps {
user: User;
}
const mapStateToProps = state => ({
user: Selectors.getUser(state),
});
export default connect(mapStateToProps)(ModGuard);

View file

@ -0,0 +1,23 @@
import { StatusEnum } from "types";
import webClient from "WebClient/WebClient";
export class AuthenticationService {
static connect(options) {
webClient.services.session.connectServer(options);
}
static disconnect() {
webClient.services.session.disconnectServer();
}
static isConnected(state) {
return state === StatusEnum.LOGGEDIN;
}
static isModerator(user) {
return user.userLevel >= webClient.pb.ServerInfo_User.UserLevelFlag.IsModerator;
}
static isAdmin() {
}
}

View file

@ -0,0 +1,7 @@
import webClient from "WebClient/WebClient";
export class ModeratorService {
static viewLogHistory(filters) {
webClient.commands.session.viewLogHistory(filters);
}
}

View file

@ -0,0 +1,11 @@
import webClient from "WebClient/WebClient";
export class RoomsService {
static joinRoom(roomId) {
webClient.commands.session.joinRoom(roomId);
}
static roomSay(roomId, message) {
webClient.commands.room.roomSay(roomId, message);
}
}

View file

@ -0,0 +1,7 @@
import { RouteEnum } from "../types";
export class RouterService {
resolveUrl(path, params) {
}
}

View file

@ -0,0 +1,19 @@
import webClient from "WebClient/WebClient";
export class SessionService {
static addToBuddyList(userName) {
webClient.commands.session.addToBuddyList(userName);
}
static removeFromBuddyList(userName) {
webClient.commands.session.removeFromBuddyList(userName);
}
static addToIgnoreList(userName) {
webClient.commands.session.addToIgnoreList(userName);
}
static removeFromIgnoreList(userName) {
webClient.commands.session.removeFromIgnoreList(userName);
}
}

View file

@ -0,0 +1,4 @@
export * from "./AuthenticationService";
export * from "./ModeratorService";
export * from "./RoomsService";
export * from "./SessionService";

View file

@ -0,0 +1,2 @@
export * from "./routes";
export * from "./user";

View file

@ -0,0 +1,10 @@
export enum RouteEnum {
PLAYER = "/player/:name",
SERVER = "/server",
ROOM = "/room/:roomId",
LOGS = "/logs",
GAME = "/game",
DECKS = "/decks",
DECK = "/deck",
ACCOUNT = "/account",
}

View file

@ -0,0 +1,3 @@
export interface User {
name: string;
}

View file

@ -0,0 +1,157 @@
const ProtoFiles = [
"admin_commands.proto",
"card_attributes.proto",
"color.proto",
"command_attach_card.proto",
"command_change_zone_properties.proto",
"command_concede.proto",
"command_create_arrow.proto",
"command_create_counter.proto",
"command_create_token.proto",
"command_deck_del.proto",
"command_deck_del_dir.proto",
"command_deck_download.proto",
"command_deck_list.proto",
"command_deck_new_dir.proto",
"command_deck_select.proto",
"command_deck_upload.proto",
"command_del_counter.proto",
"command_delete_arrow.proto",
"command_draw_cards.proto",
"command_dump_zone.proto",
"command_flip_card.proto",
"command_game_say.proto",
"command_inc_card_counter.proto",
"command_inc_counter.proto",
"command_kick_from_game.proto",
"command_leave_game.proto",
"command_move_card.proto",
"command_mulligan.proto",
"command_next_turn.proto",
"command_ready_start.proto",
"command_replay_delete_match.proto",
"command_replay_download.proto",
"command_replay_list.proto",
"command_replay_modify_match.proto",
"command_reveal_cards.proto",
"command_roll_die.proto",
"command_set_active_phase.proto",
"command_set_card_attr.proto",
"command_set_card_counter.proto",
"command_set_counter.proto",
"command_set_sideboard_lock.proto",
"command_set_sideboard_plan.proto",
"command_shuffle.proto",
"command_stop_dump_zone.proto",
"command_undo_draw.proto",
"commands.proto",
"context_concede.proto",
"context_connection_state_changed.proto",
"context_deck_select.proto",
"context_move_card.proto",
"context_mulligan.proto",
"context_ping_changed.proto",
"context_ready_start.proto",
"context_set_sideboard_lock.proto",
"context_undo_draw.proto",
"event_add_to_list.proto",
"event_attach_card.proto",
"event_change_zone_properties.proto",
"event_connection_closed.proto",
"event_create_arrow.proto",
"event_create_counter.proto",
"event_create_token.proto",
"event_del_counter.proto",
"event_delete_arrow.proto",
"event_destroy_card.proto",
"event_draw_cards.proto",
"event_dump_zone.proto",
"event_flip_card.proto",
"event_game_closed.proto",
"event_game_host_changed.proto",
"event_game_joined.proto",
"event_game_say.proto",
"event_game_state_changed.proto",
"event_join.proto",
"event_join_room.proto",
"event_kicked.proto",
"event_leave.proto",
"event_leave_room.proto",
"event_list_games.proto",
"event_list_rooms.proto",
"event_move_card.proto",
"event_notify_user.proto",
"event_player_properties_changed.proto",
"event_remove_from_list.proto",
"event_replay_added.proto",
"event_reveal_cards.proto",
"event_roll_die.proto",
"event_room_say.proto",
"event_server_complete_list.proto",
"event_server_identification.proto",
"event_server_message.proto",
"event_server_shutdown.proto",
"event_set_active_phase.proto",
"event_set_active_player.proto",
"event_set_card_attr.proto",
"event_set_card_counter.proto",
"event_set_counter.proto",
"event_shuffle.proto",
"event_stop_dump_zone.proto",
"event_user_joined.proto",
"event_user_left.proto",
"event_user_message.proto",
"game_commands.proto",
"game_event.proto",
"game_event_container.proto",
"game_event_context.proto",
"game_replay.proto",
"isl_message.proto",
"moderator_commands.proto",
"move_card_to_zone.proto",
"response.proto",
"response_activate.proto",
"response_adjust_mod.proto",
"response_ban_history.proto",
"response_deck_download.proto",
"response_deck_list.proto",
"response_deck_upload.proto",
"response_dump_zone.proto",
"response_forgotpasswordrequest.proto",
"response_get_games_of_user.proto",
"response_get_user_info.proto",
"response_join_room.proto",
"response_list_users.proto",
"response_login.proto",
"response_register.proto",
"response_replay_download.proto",
"response_replay_list.proto",
"response_viewlog_history.proto",
"response_warn_history.proto",
"response_warn_list.proto",
"room_commands.proto",
"room_event.proto",
"server_message.proto",
"serverinfo_arrow.proto",
"serverinfo_ban.proto",
"serverinfo_card.proto",
"serverinfo_cardcounter.proto",
"serverinfo_chat_message.proto",
"serverinfo_counter.proto",
"serverinfo_deckstorage.proto",
"serverinfo_game.proto",
"serverinfo_gametype.proto",
"serverinfo_player.proto",
"serverinfo_playerping.proto",
"serverinfo_playerproperties.proto",
"serverinfo_replay.proto",
"serverinfo_replay_match.proto",
"serverinfo_room.proto",
"serverinfo_user.proto",
"serverinfo_warning.proto",
"serverinfo_zone.proto",
"session_commands.proto",
"session_event.proto",
];
export default ProtoFiles;

View file

@ -0,0 +1,329 @@
import protobuf from "protobufjs";
import { StatusEnum } from "types";
import * as roomEvents from "./events/RoomEvents";
import * as sessionEvents from "./events/SessionEvents";
import { RoomService, SessionService } from "./services";
import { RoomCommands, SessionCommands } from "./commands";
import ProtoFiles from "./ProtoFiles";
const roomEventKeys = Object.keys(roomEvents);
const sessionEventKeys = Object.keys(sessionEvents);
interface ApplicationCommands {
room: RoomCommands;
session: SessionCommands;
}
interface ApplicationServices {
room: RoomService;
session: SessionService;
}
export class WebClient {
private socket: WebSocket;
private status: StatusEnum = StatusEnum.DISCONNECTED;
private keepalivecb;
private lastPingPending = false;
private cmdId = 0;
private pendingCommands = {};
public commands: ApplicationCommands;
public services: ApplicationServices;
public protocolVersion = 14;
public pb;
public clientConfig = {
"clientver" : "webclient-1.0 (2019-10-31)",
"clientfeatures" : [
"client_id",
"client_ver",
"feature_set",
"room_chat_history",
"client_warnings",
/* unimplemented features */
"forgot_password",
"idle_client",
"mod_log_lookup",
"user_ban_history",
// satisfy server reqs for POC
"websocket",
"2.6.1_min_version",
"2.7.0_min_version",
]
};
public options: any = {
host: "",
port: "",
user: "",
pass: "",
debug: false,
autojoinrooms: true,
keepalive: 5000
};
constructor() {
const files = ProtoFiles.map(file => `${WebClient.PB_FILE_DIR}/${file}`);
this.pb = new protobuf.Root();
this.pb.load(files, { keepCase: false }, (err, root) => {
if (err) {
throw err;
}
});
// This sucks. I can"t seem to get out of this
// circular dependency trap, so this is my current best.
this.commands = {
room: new RoomCommands(this),
session: new SessionCommands(this),
};
this.services = {
room: new RoomService(this),
session: new SessionService(this),
};
console.log(this);
}
private clearStores() {
this.services.room.clearStore();
this.services.session.clearStore();
}
public updateStatus(status, description) {
console.log(`Status: [${status}]: ${description}`);
this.status = status;
this.services.session.updateStatus(status, description);
if (status === StatusEnum.DISCONNECTED) {
this.clearStores();
this.endPingLoop();
this.resetConnectionvars();
}
}
public resetConnectionvars() {
this.cmdId = 0;
this.pendingCommands = {};
this.lastPingPending = false;
}
public sendCommand(cmd, callback) {
this.cmdId++;
cmd["cmdId"] = this.cmdId;
this.pendingCommands[this.cmdId] = callback;
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(this.pb.CommandContainer.encode(cmd).finish());
this.debug(() => console.log("Sent: " + cmd.toString()));
} else {
this.debug(() => console.log("Send: Not connected"));
}
}
public sendRoomCommand(roomId, roomCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"roomId" : roomId,
"roomCommand" : [ roomCmd ]
});
this.sendCommand(cmd, raw => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendSessionCommand(sesCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"sessionCommand" : [ sesCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public sendModeratorCommand(modCmd, callback?) {
const cmd = this.pb.CommandContainer.create({
"moderatorCommand" : [ modCmd ]
});
this.sendCommand(cmd, (raw) => {
this.debug(() => console.log(raw));
if (callback) {
callback(raw);
}
});
}
public startPingLoop() {
this.keepalivecb = setInterval(() => {
// check if the previous ping got no reply
if (this.lastPingPending) {
this.disconnect();
this.updateStatus(StatusEnum.DISCONNECTED, "Connection timeout");
}
// stop the ping loop if we"re disconnected
if (this.status !== StatusEnum.LOGGEDIN) {
this.endPingLoop();
return;
}
// send a ping
this.lastPingPending = true;
const ping = this.pb.Command_Ping.create();
const command = this.pb.SessionCommand.create({
".Command_Ping.ext" : ping
});
this.sendSessionCommand(command, () => this.lastPingPending = false);
}, this.options.keepalive);
}
private endPingLoop() {
clearInterval(this.keepalivecb);
this.keepalivecb = null;
}
public connect(options) {
this.options = { ...this.options, ...options };
const { host, port } = this.options;
const protocol = port === '443' ? 'wss' : 'ws';
this.socket = new WebSocket(protocol + "://" + host + ":" + port);
this.socket.binaryType = "arraybuffer"; // We are talking binary
this.socket.onopen = () => {
this.updateStatus(StatusEnum.CONNECTED, "Connected");
};
this.socket.onclose = () => {
// dont overwrite failure messages
if (this.status !== StatusEnum.DISCONNECTED) {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Closed");
}
};
this.socket.onerror = () => {
this.updateStatus(StatusEnum.DISCONNECTED, "Connection Failed");
};
this.socket.onmessage = (event) => {
const msg = this.decodeServerMessage(event);
if (msg) {
switch (msg.messageType) {
case this.pb.ServerMessage.MessageType.RESPONSE:
this.processServerResponse(msg.response);
break;
case this.pb.ServerMessage.MessageType.ROOM_EVENT:
this.processRoomEvent(msg.roomEvent, msg);
break;
case this.pb.ServerMessage.MessageType.SESSION_EVENT:
this.processSessionEvent(msg.sessionEvent, msg);
break;
case this.pb.ServerMessage.MessageType.GAME_EVENT_CONTAINER:
// @TODO
break;
}
}
}
}
public disconnect() {
if (this.socket) {
this.socket.close();
}
}
public debug(debug) {
if (this.options.debug) {
debug();
}
}
private decodeServerMessage(event) {
const uint8msg = new Uint8Array(event.data);
let msg;
try {
msg = this.pb.ServerMessage.decode(uint8msg);
this.debug(() => console.log(msg));
return msg;
} catch (err) {
console.error("Processing failed:", err);
this.debug(() => {
let str = "";
for (let i = 0; i < uint8msg.length; i++) {
str += String.fromCharCode(uint8msg[i]);
}
console.log(str);
});
return;
}
}
private processServerResponse(response) {
const cmdId = response.cmdId;
if (!this.pendingCommands.hasOwnProperty(cmdId)) {
return;
}
this.pendingCommands[cmdId](response);
delete this.pendingCommands[cmdId];
}
private processRoomEvent(response, raw) {
this.processEvent(response, roomEvents, roomEventKeys, raw);
}
private processSessionEvent(response, raw) {
this.processEvent(response, sessionEvents, sessionEventKeys, raw);
}
private processEvent(response, events, keys, raw) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const event = events[key];
const payload = response[event.id];
if (payload) {
events[key].action(payload, this, raw);
return;
}
}
}
static PB_FILE_DIR = `${process.env.PUBLIC_URL}/pb`;
}
const webClient = new WebClient();
export default webClient;

View file

@ -0,0 +1,27 @@
import * as _ from 'lodash';
import { WebClient } from "../WebClient";
export class RoomCommands {
private webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
roomSay(roomId, message) {
const trimmed = _.trim(message);
if (!trimmed) return;
var CmdRoomSay = this.webClient.pb.Command_RoomSay.create({
"message" : trimmed
});
var rc = this.webClient.pb.RoomCommand.create({
".Command_RoomSay.ext" : CmdRoomSay
});
this.webClient.sendRoomCommand(roomId, rc);
}
}

View file

@ -0,0 +1,234 @@
import { StatusEnum } from "types";
import { WebClient } from "../WebClient";
import { guid } from "../util";
export class SessionCommands {
private webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
login() {
const loginConfig = {
...this.webClient.clientConfig,
"userName" : this.webClient.options.user,
"password" : this.webClient.options.pass,
"clientid" : guid()
};
const CmdLogin = this.webClient.pb.Command_Login.create(loginConfig);
const command = this.webClient.pb.SessionCommand.create({
".Command_Login.ext" : CmdLogin
});
this.webClient.sendSessionCommand(command, raw => {
const resp = raw[".Response_Login.ext"];
this.webClient.debug(() => console.log(".Response_Login.ext", resp));
switch(raw.responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { buddyList, ignoreList, userInfo } = resp;
this.webClient.services.session.updateBuddyList(buddyList);
this.webClient.services.session.updateIgnoreList(ignoreList);
this.webClient.services.session.updateUser(userInfo);
this.webClient.commands.session.listUsers();
this.webClient.commands.session.listRooms();
this.webClient.updateStatus(StatusEnum.LOGGEDIN, "Logged in.");
this.webClient.startPingLoop();
break;
case this.webClient.pb.Response.ResponseCode.RespClientUpdateRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: missing features");
break;
case this.webClient.pb.Response.ResponseCode.RespWrongPassword:
case this.webClient.pb.Response.ResponseCode.RespUsernameInvalid:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: incorrect username or password");
break;
case this.webClient.pb.Response.ResponseCode.RespWouldOverwriteOldSession:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: duplicated user session");
break;
case this.webClient.pb.Response.ResponseCode.RespUserIsBanned:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: banned user");
break;
case this.webClient.pb.Response.ResponseCode.RespRegistrationRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: registration required");
break;
case this.webClient.pb.Response.ResponseCode.RespClientIdRequired:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: missing client ID");
break;
case this.webClient.pb.Response.ResponseCode.RespContextError:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: server error");
break;
case this.webClient.pb.Response.ResponseCode.RespAccountNotActivated:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: account not activated");
break;
default:
this.webClient.updateStatus(StatusEnum.DISCONNECTED, "Login failed: unknown error " + raw.responseCode);
}
});
}
listUsers() {
const CmdListUsers = this.webClient.pb.Command_ListUsers.create();
const sc = this.webClient.pb.SessionCommand.create({
".Command_ListUsers.ext" : CmdListUsers
});
this.webClient.sendSessionCommand(sc, raw => {
const { responseCode } = raw;
const response = raw[".Response_ListUsers.ext"];
if (response) {
switch (responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
this.webClient.services.session.updateUsers(response.userList);
break;
default:
console.log(`Failed to fetch Server Rooms [${responseCode}] : `, raw);
}
}
});
}
listRooms() {
const CmdListRooms = this.webClient.pb.Command_ListRooms.create();
const sc = this.webClient.pb.SessionCommand.create({
".Command_ListRooms.ext" : CmdListRooms
});
this.webClient.sendSessionCommand(sc);
}
joinRoom(roomId: string) {
const CmdJoinRoom = this.webClient.pb.Command_JoinRoom.create({
"roomId" : roomId
});
const sc = this.webClient.pb.SessionCommand.create({
".Command_JoinRoom.ext" : CmdJoinRoom
});
this.webClient.sendSessionCommand(sc, (raw) => {
const { responseCode } = raw;
let error;
switch(responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { roomInfo } = raw[".Response_JoinRoom.ext"];
this.webClient.services.room.joinRoom(roomInfo);
this.webClient.debug(() => console.log("Join Room: ", roomInfo.name));
return;
case this.webClient.pb.Response.ResponseCode.RespNameNotFound:
error = "Failed to join the room: it doesn\"t exist on the server.";
break;
case this.webClient.pb.Response.ResponseCode.RespContextError:
error = "The server thinks you are in the room but Cockatrice is unable to display it. Try restarting Cockatrice.";
break;
case this.webClient.pb.Response.ResponseCode.RespUserLevelTooLow:
error = "You do not have the required permission to join this room.";
break;
default:
error = "Failed to join the room due to an unknown error.";
break;
}
if (error) {
console.error(responseCode, error);
}
});
}
addToBuddyList(userName) {
this.addToList('buddy', userName);
}
removeFromBuddyList(userName) {
this.removeFromList('buddy', userName);
}
addToIgnoreList(userName) {
this.addToList('ignore', userName);
}
removeFromIgnoreList(userName) {
this.removeFromList('ignore', userName);
}
addToList(list: string, userName: string) {
const CmdAddToList = this.webClient.pb.Command_AddToList.create({ list, userName });
const sc = this.webClient.pb.SessionCommand.create({
".Command_AddToList.ext" : CmdAddToList
});
this.webClient.sendSessionCommand(sc, ({ responseCode }) => {
// @TODO: filter responseCode, pop snackbar for error
this.webClient.debug(() => console.log('Added to List Response: ', responseCode));
});
}
removeFromList(list: string, userName: string) {
const CmdRemoveFromList = this.webClient.pb.Command_RemoveFromList.create({ list, userName });
const sc = this.webClient.pb.SessionCommand.create({
".Command_RemoveFromList.ext" : CmdRemoveFromList
});
this.webClient.sendSessionCommand(sc, ({ responseCode }) => {
// @TODO: filter responseCode, pop snackbar for error
this.webClient.debug(() => console.log('Removed from List Response: ', responseCode));
});
}
viewLogHistory(filters) {
const CmdViewLogHistory = this.webClient.pb.Command_ViewLogHistory.create(filters);
const sc = this.webClient.pb.ModeratorCommand.create({
".Command_ViewLogHistory.ext" : CmdViewLogHistory
});
this.webClient.sendModeratorCommand(sc, (raw) => {
const { responseCode } = raw;
let error;
switch(responseCode) {
case this.webClient.pb.Response.ResponseCode.RespOk:
const { logMessage } = raw[".Response_ViewLogHistory.ext"];
console.log("Response_ViewLogHistory: ", logMessage)
this.webClient.services.session.viewLogs(logMessage)
this.webClient.debug(() => console.log("View Log History: ", logMessage));
return;
default:
error = "Failed to retrieve log history.";
break;
}
if (error) {
console.error(responseCode, error);
}
});
}
}

View file

@ -0,0 +1,2 @@
export * from "./RoomCommands";
export * from "./SessionCommands";

View file

@ -0,0 +1,7 @@
export const JoinRoom = {
id: ".Event_JoinRoom.ext",
action: ({ userInfo }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.userJoined(roomId, userInfo);
}
};

View file

@ -0,0 +1,7 @@
export const LeaveRoom = {
id: ".Event_LeaveRoom.ext",
action: ({ name }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.userLeft(roomId, name);
}
};

View file

@ -0,0 +1,7 @@
export const ListGames = {
id: ".Event_ListGames.ext",
action: ({ gameList }, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.updateGames(roomId, gameList);
}
};

View file

@ -0,0 +1,7 @@
export const RoomSay = {
id: ".Event_RoomSay.ext",
action: (message, webClient, { roomEvent }) => {
const { roomId } = roomEvent;
webClient.services.room.addMessage(roomId, message);
}
};

View file

@ -0,0 +1,4 @@
export * from "./JoinRoom";
export * from "./LeaveRoom";
export * from "./ListGames";
export * from "./RoomSay";

View file

@ -0,0 +1,18 @@
export const AddToList = {
id: ".Event_AddToList.ext",
action: ({ listName, userInfo}, webClient) => {
switch (listName) {
case 'buddy': {
webClient.services.session.addToBuddyList(userInfo);
break;
}
case 'ignore': {
webClient.services.session.addToIgnoreList(userInfo);
break;
}
default: {
webClient.debug(() => console.log('Attempted to add to unknown list: ', listName));
}
}
}
};

View file

@ -0,0 +1,39 @@
import { StatusEnum } from "types";
export const ConnectionClosed = {
id: ".Event_ConnectionClosed.ext",
action: ({ reason }, webClient) => {
let message = "";
// @TODO (5)
switch(reason) {
case webClient.pb.Event_ConnectionClosed.CloseReason.USER_LIMIT_REACHED:
message = "The server has reached its maximum user capacity";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.TOO_MANY_CONNECTIONS:
message = "There are too many concurrent connections from your address";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.BANNED:
message = "You are banned";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.DEMOTED:
message = "You were demoted";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.SERVER_SHUTDOWN:
message = "Scheduled server shutdown";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.USERNAMEINVALID:
message = "Invalid username";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.LOGGEDINELSEWERE:
message = "You have been logged out due to logging in at another location";
break;
case webClient.pb.Event_ConnectionClosed.CloseReason.OTHER:
default:
message = "Unknown reason";
break;
}
webClient.updateStatus(StatusEnum.DISCONNECTED, message);
}
};

View file

@ -0,0 +1,16 @@
import * as _ from "lodash";
export const ListRooms = {
id: ".Event_ListRooms.ext",
action: ({ roomList }, webClient) => {
webClient.services.room.updateRooms(roomList);
if (webClient.options.autojoinrooms) {
_.each(roomList, ({ autoJoin, roomId }) => {
if (autoJoin) {
webClient.commands.session.joinRoom(roomId);
}
});
}
}
};

View file

@ -0,0 +1,6 @@
export const NotifyUser = {
id: ".Event_NotifyUser.ext",
action: (payload) => {
// console.info("Event_NotifyUser", payload);
}
};

View file

@ -0,0 +1,6 @@
export const PlayerPropertiesChanges = {
id: ".Event_PlayerPropertiesChanges.ext",
action: (payload) => {
// console.info("Event_PlayerPropertiesChanges", payload);
}
};

View file

@ -0,0 +1,18 @@
export const RemoveFromList = {
id: ".Event_RemoveFromList.ext",
action: ({ listName, userName }, webClient) => {
switch (listName) {
case 'buddy': {
webClient.services.session.removeFromBuddyList(userName);
break;
}
case 'ignore': {
webClient.services.session.removeFromIgnoreList(userName);
break;
}
default: {
webClient.debug(() => console.log('Attempted to remove from unknown list: ', listName));
}
}
}
};

View file

@ -0,0 +1,19 @@
import { StatusEnum } from "types";
export const ServerIdentification = {
id: ".Event_ServerIdentification.ext",
action: (info, webClient, _raw) => {
const { serverName, serverVersion, protocolVersion } = info;
if (protocolVersion !== webClient.protocolVersion) {
webClient.disconnect();
webClient.updateStatus(StatusEnum.DISCONNECTED, "Protocol version mismatch: " + protocolVersion);
return;
}
webClient.resetConnectionvars();
webClient.updateStatus(StatusEnum.LOGGINGIN, "Logging in...");
webClient.services.session.updateInfo(serverName, serverVersion);
webClient.commands.session.login();
}
};

View file

@ -0,0 +1,6 @@
export const ServerMessage = {
id: ".Event_ServerMessage.ext",
action: ({ message }, webClient) => {
webClient.services.session.serverMessage(message);
}
};

View file

@ -0,0 +1,6 @@
export const ServerShutdown = {
id: ".Event_ServerShutdown.ext",
action: (payload, webClient) => {
// console.info("Event_ServerShutdown", payload);
}
};

View file

@ -0,0 +1,6 @@
export const UserJoined = {
id: ".Event_UserJoined.ext",
action: ({ userInfo }, webClient) => {
webClient.services.session.userJoined(userInfo);
}
};

View file

@ -0,0 +1,6 @@
export const UserLeft = {
id: ".Event_UserLeft.ext",
action: ({ name }, webClient) => {
webClient.services.session.userLeft(name);
}
};

View file

@ -0,0 +1,6 @@
export const UserMessage = {
id: ".Event_UserMessage.ext",
action: (payload) => {
// console.info("Event_UserMessage", payload);
}
};

View file

@ -0,0 +1,12 @@
export * from "./ConnectionClosed";
export * from "./ListRooms";
export * from "./AddToList";
export * from "./RemoveFromList";
export * from "./NotifyUser"; // @TODO
export * from "./PlayerPropertiesChanges"; // @TODO
export * from "./ServerIdentification";
export * from "./ServerMessage";
export * from "./ServerShutdown"; // @TODO
export * from "./UserJoined";
export * from "./UserLeft";
export * from "./UserMessage"; // @TODO

View file

@ -0,0 +1,44 @@
export class NormalizeService {
// Flatten room gameTypes into map object
static normalizeRoomInfo(roomInfo) {
roomInfo.gametypeMap = {};
const { gametypeList, gametypeMap, gameList } = roomInfo;
gametypeList.reduce((map, type) => {
map[type.gameTypeId] = type.description;
return map;
}, gametypeMap);
gameList.forEach((game) => NormalizeService.normalizeGameObject(game, gametypeMap));
}
// Flatten gameTypes[] into gameType field
// Default sortable values ("" || 0 || -1)
static normalizeGameObject(game, gametypeMap) {
const { gameTypes, description } = game;
const hasType = gameTypes && gameTypes.length;
game.gameType = hasType ? gametypeMap[gameTypes[0]] : "";
game.description = description || "";
}
// Flatten logs[] into object mapped by targetType (room, game, chat)
static normalizeLogs(logs) {
return logs.reduce((obj, log) => {
const { targetType } = log;
obj[targetType] = obj[targetType] || [];
obj[targetType].push(log);
return obj;
}, {});
}
// messages sent by current user dont have their username prepended
static normalizeUserMessage(message) {
const { name } = message;
if (name) {
message.message = `${name}: ${message.message}`;
}
}
}

View file

@ -0,0 +1,53 @@
import { Dispatch, Selectors } from "store/rooms";
import { store } from "store";
import { WebClient } from "../WebClient";
import { NormalizeService } from "./NormalizeService";
export class RoomService {
webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
clearStore() {
Dispatch.clearStore();
}
joinRoom(roomInfo) {
NormalizeService.normalizeRoomInfo(roomInfo);
Dispatch.joinRoom(roomInfo);
}
updateRooms(rooms) {
Dispatch.updateRooms(rooms);
}
updateGames(roomId, gameList) {
const game = gameList[0];
if (!game.gameType) {
const { gametypeMap } = Selectors.getRoom(store.getState(), roomId);
NormalizeService.normalizeGameObject(game, gametypeMap);
}
Dispatch.updateGames(roomId, gameList);
}
addMessage(roomId, message) {
NormalizeService.normalizeUserMessage(message);
Dispatch.addMessage(roomId, message);
}
userJoined(roomId, user) {
Dispatch.userJoined(roomId, user);
}
userLeft(roomId, name) {
Dispatch.userLeft(roomId, name);
}
}

View file

@ -0,0 +1,94 @@
import { Dispatch, ServerConnectParams } from "store/server";
import { StatusEnum } from "types";
import { sanitizeHtml } from "../util";
import { WebClient } from "../WebClient";
import { NormalizeService } from "./NormalizeService";
export class SessionService {
webClient: WebClient;
constructor(webClient) {
this.webClient = webClient;
}
clearStore() {
Dispatch.clearStore();
}
connectServer(options: ServerConnectParams) {
Dispatch.connectServer();
this.webClient.updateStatus(StatusEnum.CONNECTING, "Connecting...");
this.webClient.connect(options);
}
disconnectServer() {
this.webClient.updateStatus(StatusEnum.DISCONNECTING, "Disconnecting...");
this.webClient.disconnect();
}
connectionClosed(reason) {
Dispatch.connectionClosed(reason);
}
updateBuddyList(buddyList) {
Dispatch.updateBuddyList(buddyList);
}
addToBuddyList(user) {
Dispatch.addToBuddyList(user);
}
removeFromBuddyList(userName) {
Dispatch.removeFromBuddyList(userName);
}
updateIgnoreList(ignoreList) {
Dispatch.updateIgnoreList(ignoreList);
}
addToIgnoreList(user) {
Dispatch.addToIgnoreList(user);
}
removeFromIgnoreList(userName) {
Dispatch.removeFromIgnoreList(userName);
}
updateInfo(name, version) {
Dispatch.updateInfo(name, version);
}
updateStatus(state, description) {
Dispatch.updateStatus(state, description);
if (state === StatusEnum.DISCONNECTED) {
this.connectionClosed({ reason: description });
}
}
updateUser(user) {
Dispatch.updateUser(user);
}
updateUsers(users) {
Dispatch.updateUsers(users);
}
userJoined(user) {
Dispatch.userJoined(user);
}
userLeft(userId) {
Dispatch.userLeft(userId);
}
viewLogs(logs) {
Dispatch.viewLogs(NormalizeService.normalizeLogs(logs));
}
serverMessage(message) {
Dispatch.serverMessage(sanitizeHtml(message));
}
}

View file

@ -0,0 +1,3 @@
export * from "./NormalizeService";
export * from "./RoomService";
export * from "./SessionService";

View file

@ -0,0 +1,8 @@
function s4() {
const s4 = Math.floor((1 + Math.random()) * 0x10000);
return s4.toString(16).substring(1);
}
export function guid() {
return s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4();
}

View file

@ -0,0 +1,2 @@
export * from "./guid.util";
export * from "./sanitizeHtml.util";

View file

@ -0,0 +1,51 @@
import $ from "jquery";
export function sanitizeHtml(msg) {
const $div = $("<div>").html(msg);
const whitelist = {
tags: "br,a,img,center,b,font",
attrs: ["href","color"],
href: ["http://","https://","ftp://","//"]
};
// remove all tags, attributes, and href protocols except some
enforceTagWhitelist($div, whitelist.tags);
enforceAttrWhitelist($div, whitelist.attrs);
enforceHrefWhitelist($div, whitelist.href);
return $div.html();
}
function enforceTagWhitelist($el, tags) {
$el.find("*").not(tags).each(function() {
$(this).replaceWith(this.innerHTML);
});
}
function enforceAttrWhitelist($el, attrs) {
$el.find("*").each(function() {
var attributes = this.attributes;
var i = attributes.length;
while( i-- ) {
var attr = attributes[i];
if( $.inArray(attr.name,attrs) === -1 )
this.removeAttributeNode(attr);
}
});
}
function enforceHrefWhitelist($el, hrefs) {
$el.find("[href]").each(function() {
const $_el = $(this);
const attributeValue = $_el.attr("href");
for (let protocol in hrefs) {
if (attributeValue.indexOf(hrefs[protocol]) === 0) {
$_el.attr("target", "_blank");
return;
}
}
$_el.removeAttr("href");
});
}

View file

@ -0,0 +1,7 @@
import Countries from "./countries/_Countries";
export class Images {
static Countries = [
...Countries
];
}

View file

@ -0,0 +1,6 @@
# Ignore all files
*
# Except gitignore
!.gitignore
!_Countries.tsx

View file

@ -0,0 +1,501 @@
import ad from "./ad.svg";
import ae from "./ae.svg";
import af from "./af.svg";
import ag from "./ag.svg";
import ai from "./ai.svg";
import al from "./al.svg";
import am from "./am.svg";
import ao from "./ao.svg";
import aq from "./aq.svg";
import ar from "./ar.svg";
import as from "./as.svg";
import at from "./at.svg";
import au from "./au.svg";
import aw from "./aw.svg";
import ax from "./ax.svg";
import az from "./az.svg";
import ba from "./ba.svg";
import bb from "./bb.svg";
import bd from "./bd.svg";
import be from "./be.svg";
import bf from "./bf.svg";
import bg from "./bg.svg";
import bh from "./bh.svg";
import bi from "./bi.svg";
import bj from "./bj.svg";
import bl from "./bl.svg";
import bm from "./bm.svg";
import bn from "./bn.svg";
import bo from "./bo.svg";
import bq from "./bq.svg";
import br from "./br.svg";
import bs from "./bs.svg";
import bt from "./bt.svg";
import bv from "./bv.svg";
import bw from "./bw.svg";
import by from "./by.svg";
import bz from "./bz.svg";
import ca from "./ca.svg";
import cc from "./cc.svg";
import cd from "./cd.svg";
import cf from "./cf.svg";
import cg from "./cg.svg";
import ch from "./ch.svg";
import ci from "./ci.svg";
import ck from "./ck.svg";
import cl from "./cl.svg";
import cm from "./cm.svg";
import cr from "./cr.svg";
import cu from "./cu.svg";
import cv from "./cv.svg";
import cw from "./cw.svg";
import cx from "./cx.svg";
import cy from "./cy.svg";
import cz from "./cz.svg";
import de from "./de.svg";
import dj from "./dj.svg";
import dk from "./dk.svg";
import dm from "./dm.svg";
import _do from "./do.svg";
import dz from "./dz.svg";
import ec from "./ec.svg";
import ee from "./ee.svg";
import eg from "./eg.svg";
import eh from "./eh.svg";
import er from "./er.svg";
import es from "./es.svg";
import et from "./et.svg";
import eu from "./eu.svg";
import fi from "./fi.svg";
import fj from "./fj.svg";
import fk from "./fk.svg";
import fm from "./fm.svg";
import fo from "./fo.svg";
import fr from "./fr.svg";
import ga from "./ga.svg";
import gb from "./gb.svg";
import gd from "./gd.svg";
import ge from "./ge.svg";
import gf from "./gf.svg";
import gg from "./gg.svg";
import gh from "./gh.svg";
import gi from "./gi.svg";
import gl from "./gl.svg";
import gm from "./gm.svg";
import gn from "./gn.svg";
import gp from "./gp.svg";
import gq from "./gq.svg";
import gr from "./gr.svg";
import gs from "./gs.svg";
import gt from "./gt.svg";
import gu from "./gu.svg";
import gw from "./gw.svg";
import gy from "./gy.svg";
import hk from "./hk.svg";
import hm from "./hm.svg";
import hn from "./hn.svg";
import hr from "./hr.svg";
import ht from "./ht.svg";
import hu from "./hu.svg";
import id from "./id.svg";
import ie from "./ie.svg";
import il from "./il.svg";
import im from "./im.svg";
import _in from "./in.svg";
import io from "./io.svg";
import iq from "./iq.svg";
import ir from "./ir.svg";
import is from "./is.svg";
import it from "./it.svg";
import je from "./je.svg";
import jm from "./jm.svg";
import jo from "./jo.svg";
import jp from "./jp.svg";
import ke from "./ke.svg";
import kg from "./kg.svg";
import kh from "./kh.svg";
import ki from "./ki.svg";
import km from "./km.svg";
import kn from "./kn.svg";
import kp from "./kp.svg";
import kr from "./kr.svg";
import kw from "./kw.svg";
import ky from "./ky.svg";
import kz from "./kz.svg";
import la from "./la.svg";
import lb from "./lb.svg";
import lc from "./lc.svg";
import li from "./li.svg";
import lk from "./lk.svg";
import lr from "./lr.svg";
import ls from "./ls.svg";
import lt from "./lt.svg";
import lu from "./lu.svg";
import lv from "./lv.svg";
import ly from "./ly.svg";
import ma from "./ma.svg";
import mc from "./mc.svg";
import md from "./md.svg";
import me from "./me.svg";
import mf from "./mf.svg";
import mg from "./mg.svg";
import mh from "./mh.svg";
import mk from "./mk.svg";
import ml from "./ml.svg";
import mm from "./mm.svg";
import mn from "./mn.svg";
import mo from "./mo.svg";
import mp from "./mp.svg";
import mq from "./mq.svg";
import mr from "./mr.svg";
import ms from "./ms.svg";
import mt from "./mt.svg";
import mu from "./mu.svg";
import mv from "./mv.svg";
import mw from "./mw.svg";
import mx from "./mx.svg";
import my from "./my.svg";
import mz from "./mz.svg";
import na from "./na.svg";
import nc from "./nc.svg";
import ne from "./ne.svg";
import nf from "./nf.svg";
import ng from "./ng.svg";
import ni from "./ni.svg";
import nl from "./nl.svg";
import no from "./no.svg";
import np from "./np.svg";
import nr from "./nr.svg";
import nu from "./nu.svg";
import nz from "./nz.svg";
import om from "./om.svg";
import pa from "./pa.svg";
import pe from "./pe.svg";
import pf from "./pf.svg";
import pg from "./pg.svg";
import ph from "./ph.svg";
import pk from "./pk.svg";
import pl from "./pl.svg";
import pm from "./pm.svg";
import pn from "./pn.svg";
import pr from "./pr.svg";
import ps from "./ps.svg";
import pt from "./pt.svg";
import pw from "./pw.svg";
import py from "./py.svg";
import qa from "./qa.svg";
import re from "./re.svg";
import ro from "./ro.svg";
import rs from "./rs.svg";
import ru from "./ru.svg";
import rw from "./rw.svg";
import sa from "./sa.svg";
import sb from "./sb.svg";
import sc from "./sc.svg";
import sd from "./sd.svg";
import se from "./se.svg";
import sg from "./sg.svg";
import sh from "./sh.svg";
import si from "./si.svg";
import sj from "./sj.svg";
import sk from "./sk.svg";
import sl from "./sl.svg";
import sm from "./sm.svg";
import sn from "./sn.svg";
import so from "./so.svg";
import sr from "./sr.svg";
import ss from "./ss.svg";
import st from "./st.svg";
import sv from "./sv.svg";
import sx from "./sx.svg";
import sy from "./sy.svg";
import sz from "./sz.svg";
import tc from "./tc.svg";
import td from "./td.svg";
import tf from "./tf.svg";
import tg from "./tg.svg";
import th from "./th.svg";
import tj from "./tj.svg";
import tk from "./tk.svg";
import tl from "./tl.svg";
import tm from "./tm.svg";
import tn from "./tn.svg";
import to from "./to.svg";
import tr from "./tr.svg";
import tt from "./tt.svg";
import tv from "./tv.svg";
import tw from "./tw.svg";
import tz from "./tz.svg";
import ua from "./ua.svg";
import ug from "./ug.svg";
import um from "./um.svg";
import us from "./us.svg";
import uy from "./uy.svg";
import uz from "./uz.svg";
import va from "./va.svg";
import vc from "./vc.svg";
import ve from "./ve.svg";
import vg from "./vg.svg";
import vi from "./vi.svg";
import vn from "./vn.svg";
import vu from "./vu.svg";
import wf from "./wf.svg";
import ws from "./ws.svg";
import ye from "./ye.svg";
import yt from "./yt.svg";
import za from "./za.svg";
import zm from "./zm.svg";
import zw from "./zw.svg";
const Countries = [
ad,
ae,
af,
ag,
ai,
al,
am,
ao,
aq,
ar,
as,
at,
au,
aw,
ax,
az,
ba,
bb,
bd,
be,
bf,
bg,
bh,
bi,
bj,
bl,
bm,
bn,
bo,
bq,
br,
bs,
bt,
bv,
bw,
by,
bz,
ca,
cc,
cd,
cf,
cg,
ch,
ci,
ck,
cl,
cm,
cr,
cu,
cv,
cw,
cx,
cy,
cz,
de,
dj,
dk,
dm,
_do,
dz,
ec,
ee,
eg,
eh,
er,
es,
et,
eu,
fi,
fj,
fk,
fm,
fo,
fr,
ga,
gb,
gd,
ge,
gf,
gg,
gh,
gi,
gl,
gm,
gn,
gp,
gq,
gr,
gs,
gt,
gu,
gw,
gy,
hk,
hm,
hn,
hr,
ht,
hu,
id,
ie,
il,
im,
_in,
io,
iq,
ir,
is,
it,
je,
jm,
jo,
jp,
ke,
kg,
kh,
ki,
km,
kn,
kp,
kr,
kw,
ky,
kz,
la,
lb,
lc,
li,
lk,
lr,
ls,
lt,
lu,
lv,
ly,
ma,
mc,
md,
me,
mf,
mg,
mh,
mk,
ml,
mm,
mn,
mo,
mp,
mq,
mr,
ms,
mt,
mu,
mv,
mw,
mx,
my,
mz,
na,
nc,
ne,
nf,
ng,
ni,
nl,
no,
np,
nr,
nu,
nz,
om,
pa,
pe,
pf,
pg,
ph,
pk,
pl,
pm,
pn,
pr,
ps,
pt,
pw,
py,
qa,
re,
ro,
rs,
ru,
rw,
sa,
sb,
sc,
sd,
se,
sg,
sh,
si,
sj,
sk,
sl,
sm,
sn,
so,
sr,
ss,
st,
sv,
sx,
sy,
sz,
tc,
td,
tf,
tg,
th,
tj,
tk,
tl,
tm,
tn,
to,
tr,
tt,
tv,
tw,
tz,
ua,
ug,
um,
us,
uy,
uz,
va,
vc,
ve,
vg,
vi,
vn,
vu,
wf,
ws,
ye,
yt,
za,
zm,
zw,
];
export default Countries;

57
webclient/src/index.css Normal file
View file

@ -0,0 +1,57 @@
:root {
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
strong,
b {
font-weight: bold;
}
.overflow-scroll {
overflow-y: scroll; /* has to be scroll, not auto */
-webkit-overflow-scrolling: touch;
}
.plain-link {
color: inherit;
text-decoration: none;
}
.single-line-ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}

7
webclient/src/index.tsx Normal file
View file

@ -0,0 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import AppShell from "./AppShell/AppShell";
ReactDOM.render(<AppShell />, document.getElementById("root"));

1
webclient/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,147 @@
import { SortBy, SortDirection, User } from "types";
export class SortUtil {
static sortByField(arr: any[], sortBy: SortBy): void {
if (arr.length) {
const field = SortUtil.resolveFieldChain(arr[0], sortBy.field);
const fieldType = typeof field;
if (fieldType === "string") {
SortUtil.sortByString(arr, sortBy);
return;
}
if (fieldType === "number") {
SortUtil.sortByNumber(arr, sortBy);
return;
}
throw new Error("SortField must resolve to either a string or number");
}
}
static sortByFields(arr: any[], sorts: SortBy[]) {
if (arr.length) {
arr.sort((a, b) => {
for (let i = 0; i < sorts.length; i++) {
const sortBy = sorts[i];
const field = SortUtil.resolveFieldChain(arr[0], sortBy.field);
const fieldType = typeof field;
if (fieldType === "string") {
const result = SortUtil.stringComparator(a, b, sortBy);
if (result) {
return result;
}
}
if (fieldType === "number") {
const result = SortUtil.numberComparator(a, b, sortBy);
if (result) {
return result;
}
}
throw new Error("SortField must resolve to either a string or number");
}
return 0;
})
}
}
static sortUsersByField(users: User[], sortBy: SortBy) {
if (users.length) {
users.sort((a, b) => SortUtil.userComparator(a, b, sortBy))
}
}
static toggleSortBy(field: string, sortBy: SortBy) {
const sameField = field === sortBy.field;
const isASC = sortBy.order === SortDirection.ASC;
return {
field,
order: sameField && isASC ? SortDirection.DESC : SortDirection.ASC
}
}
private static sortByNumber(arr: any[], sortBy: SortBy): void {
arr.sort((a, b) => SortUtil.numberComparator(a, b, sortBy));
}
private static sortByString(arr: any[], sortBy: SortBy): void {
arr.sort((a, b) => SortUtil.stringComparator(a, b, sortBy));
}
private static userComparator(a, b, sortBy, sortByUserLevel = true) {
if (sortByUserLevel) {
const adminSortBy = {
field: "userLevel",
order: SortDirection.DESC
};
const adminSorted = SortUtil.numberComparator(a, b, adminSortBy);
if (adminSorted) {
return adminSorted;
}
}
const sorted = SortUtil.stringComparator(a, b, sortBy);
if (sorted) {
return sorted;
}
return 0;
}
private static numberComparator(a, b, { field, order }: SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field);
if (order === SortDirection.ASC) {
return aResolved - bResolved;
} else {
return bResolved - aResolved;
}
}
private static stringComparator(a, b, { field, order }: SortBy) {
const aResolved = SortUtil.resolveFieldChain(a, field);
const bResolved = SortUtil.resolveFieldChain(b, field);
// Force empty strings to sort to bottom
if (!aResolved && !bResolved) { return 0; }
if (!aResolved) { return 1; }
if (!bResolved) { return -1; }
if (order === SortDirection.ASC) {
return aResolved.localeCompare(bResolved);
} else {
return bResolved.localeCompare(aResolved);
}
}
private static resolveFieldChain(obj: object, field: string) {
const links = field.split(".");
if (links.length > 1) {
return links.reduce((obj, link) => {
const parsed = parseInt(link, 10);
if (parsed.toLocaleString() === "NaN") {
return obj[link];
} else {
return obj[parsed];
}
}, obj) || null;
} else {
return obj[field];
}
}
}

View file

@ -0,0 +1 @@
export * from "./SortUtil";

Some files were not shown because too many files have changed in this diff Show more