Webatrice: card import wizard (#4397)
This commit is contained in:
parent
dde0f568d9
commit
36e5a399d5
41 changed files with 1479 additions and 35 deletions
12
webclient/package-lock.json
generated
12
webclient/package-lock.json
generated
|
@ -4613,6 +4613,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dexie": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dexie/-/dexie-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-BSFhGpngnCl1DOr+8YNwBDobRMH0ziJs2vts69VilwetHYOtEDcLqo7d/XiIphM0tJZ2rPPyAGd31lgH2Ln3nw=="
|
||||||
|
},
|
||||||
"diff-sequences": {
|
"diff-sequences": {
|
||||||
"version": "24.9.0",
|
"version": "24.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz",
|
||||||
|
@ -12843,10 +12848,9 @@
|
||||||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
||||||
},
|
},
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"version": "3.6.4",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
|
||||||
"integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==",
|
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"uglify-js": {
|
"uglify-js": {
|
||||||
"version": "3.4.10",
|
"version": "3.4.10",
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.11.4",
|
"@material-ui/core": "^4.11.4",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@material-ui/icons": "^4.11.2",
|
||||||
|
"dexie": "^3.0.3",
|
||||||
"jquery": "^3.4.1",
|
"jquery": "^3.4.1",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
|
@ -18,7 +19,8 @@
|
||||||
"react-window": "^1.8.5",
|
"react-window": "^1.8.5",
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.4",
|
||||||
"redux-form": "^8.2.6",
|
"redux-form": "^8.2.6",
|
||||||
"redux-thunk": "^2.3.0"
|
"redux-thunk": "^2.3.0",
|
||||||
|
"typescript": "^4.3.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'Copying shared files...' && ./copy_shared_files.sh",
|
"postinstall": "echo 'Copying shared files...' && ./copy_shared_files.sh",
|
||||||
|
@ -57,7 +59,6 @@
|
||||||
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
||||||
"@types/react-window": "^1.8.2",
|
"@types/react-window": "^1.8.2",
|
||||||
"@types/redux": "^3.6.0",
|
"@types/redux": "^3.6.0",
|
||||||
"@types/redux-form": "^8.2.0",
|
"@types/redux-form": "^8.2.0"
|
||||||
"typescript": "3.6.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
4
webclient/src/components/Card/Card.css
Normal file
4
webclient/src/components/Card/Card.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
20
webclient/src/components/Card/Card.tsx
Normal file
20
webclient/src/components/Card/Card.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { CardDTO } from 'services';
|
||||||
|
|
||||||
|
import './Card.css';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
card: CardDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = ({ card }: CardProps) => {
|
||||||
|
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
|
||||||
|
|
||||||
|
return card && (
|
||||||
|
<img className="card" src={src} alt={card?.name} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Card;
|
45
webclient/src/components/CardDetails/CardDetails.css
Normal file
45
webclient/src/components/CardDetails/CardDetails.css
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
.cardDetails {
|
||||||
|
padding: 10px;
|
||||||
|
width: calc(400px * .716);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-card {
|
||||||
|
height: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-attribute {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-attributes {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-attribute__label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-attribute__value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-text {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px;
|
||||||
|
background: rgba(0, 0, 0, .15);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-text__flavor {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDetails-text__current:not(:empty) + .cardDetails-text__flavor {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
130
webclient/src/components/CardDetails/CardDetails.tsx
Normal file
130
webclient/src/components/CardDetails/CardDetails.tsx
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { CardDTO } from 'services';
|
||||||
|
|
||||||
|
import Card from '../Card/Card';
|
||||||
|
|
||||||
|
import './CardDetails.css';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
card: CardDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @TODO: add missing fields (loyalty, hand, etc)
|
||||||
|
|
||||||
|
const CardDetails = ({ card }: CardProps) => {
|
||||||
|
return (
|
||||||
|
<div className='cardDetails'>
|
||||||
|
<div className='cardDetails-card'>
|
||||||
|
<Card card={card} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
card && (
|
||||||
|
<div>
|
||||||
|
<div className='cardDetails-attributes'>
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Name:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
(!card.power && !card.toughness) ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>P/T:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.power || 0}/{card.toughness || 0}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.manaCost ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Cost:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.manaCost.replace(/\{|\}/g, '')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.convertedManaCost ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>CMC:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.convertedManaCost}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.colorIdentity?.length ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Identity:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.colorIdentity.join('')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.colors?.length ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Color(s):</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.colors.join('')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.types?.length ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Main Type:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.types.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.type ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Type:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.type}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.side ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Side:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.side}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!card.layout ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Layout:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{card.layout}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardDetails-text'>
|
||||||
|
<div className='cardDetails-text__current'>
|
||||||
|
{card.text?.trim()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardDetails-text__flavor'>
|
||||||
|
{card.flavorText?.trim()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardDetails;
|
|
@ -0,0 +1,5 @@
|
||||||
|
.dialog-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from "react";
|
||||||
|
import Dialog from '@material-ui/core/Dialog';
|
||||||
|
import DialogContent from '@material-ui/core/DialogContent';
|
||||||
|
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||||
|
import IconButton from '@material-ui/core/IconButton';
|
||||||
|
import CloseIcon from '@material-ui/icons/Close';
|
||||||
|
import Typography from '@material-ui/core/Typography';
|
||||||
|
|
||||||
|
import { CardImportForm } from 'forms';
|
||||||
|
|
||||||
|
import './CardImportDialog.css';
|
||||||
|
|
||||||
|
const CardImportDialog = ({ classes, handleClose, isOpen }: any) => {
|
||||||
|
const handleOnClose = () => {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onClose={handleOnClose} open={isOpen}>
|
||||||
|
<DialogTitle disableTypography className="dialog-title">
|
||||||
|
<Typography variant="h6">Import Cards</Typography>
|
||||||
|
|
||||||
|
{handleOnClose ? (
|
||||||
|
<IconButton onClick={handleOnClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<CardImportForm onSubmit={handleOnClose}></CardImportForm>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardImportDialog;
|
|
@ -19,6 +19,8 @@ import { Room, RouteEnum, User } from "types";
|
||||||
import "./Header.css";
|
import "./Header.css";
|
||||||
import logo from "./logo.png";
|
import logo from "./logo.png";
|
||||||
|
|
||||||
|
import CardImportDialog from '../CardImportDialog/CardImportDialog';
|
||||||
|
|
||||||
class Header extends Component<HeaderProps> {
|
class Header extends Component<HeaderProps> {
|
||||||
state: HeaderState;
|
state: HeaderState;
|
||||||
options: string[] = [
|
options: string[] = [
|
||||||
|
@ -29,12 +31,17 @@ class Header extends Component<HeaderProps> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = { anchorEl: null };
|
this.state = {
|
||||||
|
anchorEl: null,
|
||||||
|
showCardImportDialog: false,
|
||||||
|
};
|
||||||
|
|
||||||
this.handleMenuOpen = this.handleMenuOpen.bind(this);
|
this.handleMenuOpen = this.handleMenuOpen.bind(this);
|
||||||
this.handleMenuItemClick = this.handleMenuItemClick.bind(this);
|
this.handleMenuItemClick = this.handleMenuItemClick.bind(this);
|
||||||
this.handleMenuClose = this.handleMenuClose.bind(this);
|
this.handleMenuClose = this.handleMenuClose.bind(this);
|
||||||
this.leaveRoom = this.leaveRoom.bind(this);
|
this.leaveRoom = this.leaveRoom.bind(this);
|
||||||
|
this.openImportCardWizard = this.openImportCardWizard.bind(this);
|
||||||
|
this.closeImportCardWizard = this.closeImportCardWizard.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
|
@ -65,9 +72,18 @@ class Header extends Component<HeaderProps> {
|
||||||
RoomsService.leaveRoom(roomId);
|
RoomsService.leaveRoom(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
openImportCardWizard() {
|
||||||
|
this.setState({ showCardImportDialog: true });
|
||||||
|
this.handleMenuClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeImportCardWizard() {
|
||||||
|
this.setState({ showCardImportDialog: false });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { joinedRooms, state, user } = this.props;
|
const { joinedRooms, state, user } = this.props;
|
||||||
const { anchorEl } = this.state;
|
const { anchorEl, showCardImportDialog } = this.state;
|
||||||
|
|
||||||
let options = [ ...this.options ];
|
let options = [ ...this.options ];
|
||||||
|
|
||||||
|
@ -156,6 +172,10 @@ class Header extends Component<HeaderProps> {
|
||||||
{option}
|
{option}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<MenuItem key='Import Cards' onClick={(event) => this.openImportCardWizard()}>
|
||||||
|
Import Cards
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -163,6 +183,11 @@ class Header extends Component<HeaderProps> {
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
|
<CardImportDialog
|
||||||
|
isOpen={showCardImportDialog}
|
||||||
|
handleClose={this.closeImportCardWizard}
|
||||||
|
></CardImportDialog>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -177,7 +202,8 @@ interface HeaderProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeaderState {
|
interface HeaderState {
|
||||||
anchorEl: Element
|
anchorEl: Element;
|
||||||
|
showCardImportDialog: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
|
4
webclient/src/components/Message/CardCallout.css
Normal file
4
webclient/src/components/Message/CardCallout.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.callout {
|
||||||
|
font-weight: bold;
|
||||||
|
color: green;
|
||||||
|
}
|
87
webclient/src/components/Message/CardCallout.tsx
Normal file
87
webclient/src/components/Message/CardCallout.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
import Popover from '@material-ui/core/Popover';
|
||||||
|
|
||||||
|
import { CardDTO, TokenDTO } from 'services';
|
||||||
|
|
||||||
|
import CardDetails from '../CardDetails/CardDetails';
|
||||||
|
import TokenDetails from '../TokenDetails/TokenDetails';
|
||||||
|
|
||||||
|
import './CardCallout.css';
|
||||||
|
|
||||||
|
const useStyles = makeStyles(theme => ({
|
||||||
|
popover: {
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
popoverContent: {
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CardCallout = ({ name }) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
const [card, setCard] = useState<CardDTO>(null);
|
||||||
|
const [token, setToken] = useState<TokenDTO>(null);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<Element>(null);
|
||||||
|
|
||||||
|
useMemo(async () => {
|
||||||
|
const card = await CardDTO.get(name);
|
||||||
|
if (card) {
|
||||||
|
return setCard(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await TokenDTO.get(name);
|
||||||
|
if (token) {
|
||||||
|
return setToken(token);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handlePopoverOpen = (event) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className='callout'>
|
||||||
|
<span
|
||||||
|
onMouseEnter={handlePopoverOpen}
|
||||||
|
onMouseLeave={handlePopoverClose}
|
||||||
|
>{card?.name || token?.name?.value || name}</span>
|
||||||
|
|
||||||
|
{
|
||||||
|
(card || token) && (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handlePopoverClose}
|
||||||
|
className={classes.popover}
|
||||||
|
classes={{
|
||||||
|
paper: classes.popoverContent,
|
||||||
|
}}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="callout-card">
|
||||||
|
{ card && ( <CardDetails card={card} /> ) }
|
||||||
|
{ token && ( <TokenDetails token={token} /> ) }
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardCallout;
|
3
webclient/src/components/Message/Message.css
Normal file
3
webclient/src/components/Message/Message.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.link {
|
||||||
|
color: blue;
|
||||||
|
}
|
105
webclient/src/components/Message/Message.tsx
Normal file
105
webclient/src/components/Message/Message.tsx
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { NavLink, generatePath } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RouteEnum,
|
||||||
|
URL_REGEX,
|
||||||
|
MESSAGE_SENDER_REGEX,
|
||||||
|
MENTION_REGEX,
|
||||||
|
CARD_CALLOUT_REGEX,
|
||||||
|
CALLOUT_BOUNDARY_REGEX,
|
||||||
|
} from 'types';
|
||||||
|
|
||||||
|
import CardCallout from './CardCallout';
|
||||||
|
import './Message.css';
|
||||||
|
|
||||||
|
const Message = ({ message: { message, messageType, timeOf, timeReceived } }) => (
|
||||||
|
<div className='message'>
|
||||||
|
<div className='message__detail'>
|
||||||
|
<ParsedMessage message={message} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ParsedMessage = ({ message }) => {
|
||||||
|
const [messageChunks, setMessageChunks] = useState(null);
|
||||||
|
const [name, setName] = useState(null);
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
const name = message.match(MESSAGE_SENDER_REGEX);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
setName(name[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessageChunks(parseMessage(message));
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{ name && ( <strong><PlayerLink name={name} />:</strong> ) }
|
||||||
|
{ messageChunks }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayerLink = ({ name, label = name }) => (
|
||||||
|
<NavLink className="link" to={generatePath(RouteEnum.PLAYER, { name })}>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseMessage(message) {
|
||||||
|
return message.replace(MESSAGE_SENDER_REGEX, '')
|
||||||
|
.split(CARD_CALLOUT_REGEX)
|
||||||
|
.filter(chunk => !!chunk)
|
||||||
|
.map(parseChunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChunks(chunk, index) {
|
||||||
|
if (chunk.match(CARD_CALLOUT_REGEX)) {
|
||||||
|
const name = chunk.replace(CALLOUT_BOUNDARY_REGEX, '').trim();
|
||||||
|
return ( <CardCallout name={name} key={index}></CardCallout> );
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.match(URL_REGEX)) {
|
||||||
|
return parseUrlChunk(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.match(MENTION_REGEX)) {
|
||||||
|
return parseMentionChunk(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrlChunk(chunk) {
|
||||||
|
return chunk.split(URL_REGEX)
|
||||||
|
.filter(urlChunk => !!urlChunk)
|
||||||
|
.map((urlChunk, index) => {
|
||||||
|
if (urlChunk.match(URL_REGEX)) {
|
||||||
|
return ( <a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a> );
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlChunk;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMentionChunk(chunk) {
|
||||||
|
return chunk.split(MENTION_REGEX)
|
||||||
|
.filter(mentionChunk => !!mentionChunk)
|
||||||
|
.map((mentionChunk, index) => {
|
||||||
|
const mention = mentionChunk.match(MENTION_REGEX);
|
||||||
|
|
||||||
|
if (mention) {
|
||||||
|
const name = mention[0].substr(1);
|
||||||
|
return ( <PlayerLink name={name} label={mention} key={index} /> );
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentionChunk;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Message;
|
4
webclient/src/components/Token/Token.css
Normal file
4
webclient/src/components/Token/Token.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.token {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
19
webclient/src/components/Token/Token.tsx
Normal file
19
webclient/src/components/Token/Token.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { TokenDTO } from 'services';
|
||||||
|
|
||||||
|
import './Token.css';
|
||||||
|
|
||||||
|
interface TokenProps {
|
||||||
|
token: TokenDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Token = ({ token }: TokenProps) => {
|
||||||
|
const set = Array.isArray(token?.set) ? token?.set[0] : token?.set;
|
||||||
|
return token && (
|
||||||
|
<img className="token" src={set?.picURL} alt={token?.name?.value} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Token;
|
46
webclient/src/components/TokenDetails/TokenDetails.css
Normal file
46
webclient/src/components/TokenDetails/TokenDetails.css
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
.tokenDetails {
|
||||||
|
padding: 10px;
|
||||||
|
width: calc(400px * .716);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-token {
|
||||||
|
height: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-attribute {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-attributes {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-attribute__label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-attribute__value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-text {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
background: rgba(0, 0, 0, .15);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-text__flavor {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenDetails-text__current:not(:empty) + .tokenDetails-text__flavor {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
86
webclient/src/components/TokenDetails/TokenDetails.tsx
Normal file
86
webclient/src/components/TokenDetails/TokenDetails.tsx
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { TokenDTO } from 'services';
|
||||||
|
|
||||||
|
import Token from '../Token/Token';
|
||||||
|
|
||||||
|
import './TokenDetails.css';
|
||||||
|
|
||||||
|
interface TokenProps {
|
||||||
|
token: TokenDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TokenDetails = ({ token }: TokenProps) => {
|
||||||
|
const props = token?.prop?.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='tokenDetails'>
|
||||||
|
<div className='tokenDetails-token'>
|
||||||
|
<Token token={token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
token && (
|
||||||
|
<div>
|
||||||
|
<div className='tokenDetails-attributes'>
|
||||||
|
<div className='tokenDetails-attribute'>
|
||||||
|
<span className='tokenDetails-attribute__label'>Name:</span>
|
||||||
|
<span className='tokenDetails-attribute__value'>{token.name?.value}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
(!props.pt?.value) ? null : (
|
||||||
|
<div className='tokenDetails-attribute'>
|
||||||
|
<span className='tokenDetails-attribute__label'>P/T:</span>
|
||||||
|
<span className='tokenDetails-attribute__value'>{props.pt.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!props.colors?.value ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Color(s):</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{props.colors.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!props.maintype?.value ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Main Type:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{props.maintype.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!props.type?.value ? null : (
|
||||||
|
<div className='cardDetails-attribute'>
|
||||||
|
<span className='cardDetails-attribute__label'>Type:</span>
|
||||||
|
<span className='cardDetails-attribute__value'>{props.type.value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
!token.text?.value ? null : (
|
||||||
|
<div className='tokenDetails-text'>
|
||||||
|
<div className='tokenDetails-text__current'>
|
||||||
|
{token.text.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenDetails;
|
|
@ -32,4 +32,4 @@ const Row = ({ data, index, style }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default VirtualList;
|
export default VirtualList;
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
// Common components
|
// Common components
|
||||||
|
export { default as Card } from './Card/Card';
|
||||||
|
export { default as CardDetails } from './CardDetails/CardDetails';
|
||||||
export { default as Header } from './Header/Header';
|
export { default as Header } from './Header/Header';
|
||||||
export { default as InputField } from './InputField/InputField';
|
export { default as InputField } from './InputField/InputField';
|
||||||
export { default as InputAction } from './InputAction/InputAction';
|
export { default as InputAction } from './InputAction/InputAction';
|
||||||
|
export { default as Message } from './Message/Message';
|
||||||
export { default as VirtualList } from './VirtualList/VirtualList';
|
export { default as VirtualList } from './VirtualList/VirtualList';
|
||||||
export { default as UserDisplay} from './UserDisplay/UserDisplay';
|
export { default as UserDisplay} from './UserDisplay/UserDisplay';
|
||||||
export { default as ThreePaneLayout } from './ThreePaneLayout/ThreePaneLayout';
|
export { default as ThreePaneLayout } from './ThreePaneLayout/ThreePaneLayout';
|
||||||
|
@ -13,3 +16,5 @@ export { default as ScrollToBottomOnChanges } from './ScrollToBottomOnChanges/Sc
|
||||||
export { default as AuthGuard } from './Guard/AuthGuard';
|
export { default as AuthGuard } from './Guard/AuthGuard';
|
||||||
export { default as ModGuard} from './Guard/ModGuard';
|
export { default as ModGuard} from './Guard/ModGuard';
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
export { default as CardImportDialog} from './CardImportDialog/CardImportDialog';
|
||||||
|
|
|
@ -6,12 +6,12 @@
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message-wrapper {
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
|
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:last-of-type {
|
.message-wrapper:last-of-type {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,20 @@
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { Message } from 'components';
|
||||||
|
|
||||||
import "./Messages.css";
|
import "./Messages.css";
|
||||||
|
|
||||||
const Messages = ({ messages }) => (
|
const Messages = ({ messages }) => (
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{
|
{
|
||||||
messages && messages.map(({ message, messageType, timeOf, timeReceived }) => (
|
messages && messages.map((message, index) => (
|
||||||
<div className="message" key={timeReceived}>
|
<div className="message-wrapper" key={message.timeReceived}>
|
||||||
<div className="message__detail">{ParsedMessage(message)}</div>
|
<Message message={message} />
|
||||||
</div>
|
</div>
|
||||||
) )
|
) )
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ParsedMessage = (message) => {
|
export default Messages;
|
||||||
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;
|
|
||||||
|
|
30
webclient/src/containers/Room/OpenGames.css
Normal file
30
webclient/src/containers/Room/OpenGames.css
Normal 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%;
|
||||||
|
}
|
143
webclient/src/containers/Room/OpenGames.tsx
Normal file
143
webclient/src/containers/Room/OpenGames.tsx
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
// 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, RoomsDispatch, RoomsSelectors } from "store";
|
||||||
|
import { UserDisplay } from "components";
|
||||||
|
|
||||||
|
import "./OpenGames.css";
|
||||||
|
|
||||||
|
// @TODO run interval to update timeSinceCreated
|
||||||
|
class OpenGames extends Component<OpenGamesProps> {
|
||||||
|
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);
|
||||||
|
RoomsDispatch.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 OpenGamesProps {
|
||||||
|
room: any;
|
||||||
|
sortBy: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
sortBy: RoomsSelectors.getSortGamesBy(state)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(OpenGames);
|
|
@ -11,7 +11,7 @@ import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, Aut
|
||||||
import { RoomsStateMessages, RoomsStateRooms, JoinedRooms, RoomsSelectors } from "store";
|
import { RoomsStateMessages, RoomsStateRooms, JoinedRooms, RoomsSelectors } from "store";
|
||||||
import { RouteEnum } from "types";
|
import { RouteEnum } from "types";
|
||||||
|
|
||||||
import Games from "./Games";
|
import OpenGames from "./OpenGames";
|
||||||
import Messages from "./Messages";
|
import Messages from "./Messages";
|
||||||
import SayMessage from "./SayMessage";
|
import SayMessage from "./SayMessage";
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ class Room extends Component<any> {
|
||||||
|
|
||||||
top={(
|
top={(
|
||||||
<Paper className="room-view__games overflow-scroll">
|
<Paper className="room-view__games overflow-scroll">
|
||||||
<Games room={room} />
|
<OpenGames room={room} />
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
35
webclient/src/forms/CardImportForm/CardImportForm.css
Normal file
35
webclient/src/forms/CardImportForm/CardImportForm.css
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.cardImportForm {
|
||||||
|
width: 550px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardImportForm-content.done {
|
||||||
|
font-size: 32px;
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardImportForm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardImportForm-error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-import-list {
|
||||||
|
height: 300px;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid lightgrey;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
225
webclient/src/forms/CardImportForm/CardImportForm.tsx
Normal file
225
webclient/src/forms/CardImportForm/CardImportForm.tsx
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Form, Field, reduxForm} from 'redux-form'
|
||||||
|
|
||||||
|
import Button from '@material-ui/core/Button';
|
||||||
|
import Stepper from '@material-ui/core/Stepper';
|
||||||
|
import Step from '@material-ui/core/Step';
|
||||||
|
import StepLabel from '@material-ui/core/StepLabel';
|
||||||
|
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||||
|
|
||||||
|
import { InputField, VirtualList } from 'components';
|
||||||
|
import { cardImporterService, CardDTO, SetDTO, TokenDTO } from 'services';
|
||||||
|
import { FormKey } from 'types';
|
||||||
|
|
||||||
|
import './CardImportForm.css';
|
||||||
|
|
||||||
|
const CardImportForm = (props) => {
|
||||||
|
const { handleSubmit, onSubmit:onClose } = props;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [importedCards, setImportedCards] = useState([]);
|
||||||
|
const [importedSets, setImportedSets] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) { setError(null); }
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
const steps = ['Imports sets', 'Save sets', 'Import tokens', 'Finished'];
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardDownload = ({ cardDownloadUrl }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
cardImporterService.importCards(cardDownloadUrl)
|
||||||
|
.then(({ cards, sets }) => {
|
||||||
|
setImportedCards(cards);
|
||||||
|
setImportedSets(sets);
|
||||||
|
|
||||||
|
handleNext();
|
||||||
|
})
|
||||||
|
.catch(({ message }) => setError(message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await CardDTO.bulkAdd(importedCards);
|
||||||
|
await SetDTO.bulkAdd(importedSets);
|
||||||
|
|
||||||
|
handleNext();
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('Failed to save cards');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTokenDownload = ({ tokenDownloadUrl }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
cardImporterService.importTokens(tokenDownloadUrl)
|
||||||
|
.then(async tokens => {
|
||||||
|
await TokenDTO.bulkAdd(tokens);
|
||||||
|
handleNext();
|
||||||
|
})
|
||||||
|
.catch(({ message }) => setError(message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepContent = (stepIndex) => {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0: return (
|
||||||
|
<Form className='cardImportForm' onSubmit={handleSubmit(handleCardDownload)}>
|
||||||
|
<div className='cardImportForm-item'>
|
||||||
|
<Field label='Download URL' name='cardDownloadUrl' component={InputField} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-actions'>
|
||||||
|
<Button color='primary' type='submit' disabled={loading}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-error'>
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 1: return (
|
||||||
|
<div className='cardImportForm'>
|
||||||
|
<div className='cardImportForm-content'>
|
||||||
|
<CardsImported cards={importedCards} sets={importedSets} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-actions'>
|
||||||
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
|
<Button color='primary' onClick={handleCardSave} disabled={loading}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-error'>
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2: return (
|
||||||
|
<Form className='cardImportForm' onSubmit={handleSubmit(handleTokenDownload)}>
|
||||||
|
<div className='cardImportForm-content'>
|
||||||
|
<Field label='Download URL' name='tokenDownloadUrl' component={InputField} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-actions'>
|
||||||
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
|
<Button color='primary' type='submit' disabled={loading}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-error'>
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3: return (
|
||||||
|
<div className='cardImportForm'>
|
||||||
|
<div className='cardImportForm-content done'>Finished!</div>
|
||||||
|
|
||||||
|
<div className='cardImportForm-actions'>
|
||||||
|
<BackButton click={handleBack} disabled={loading} />
|
||||||
|
<Button color='primary' onClick={onClose}>Done</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Stepper activeStep={activeStep} alternativeLabel>
|
||||||
|
{steps.map((label) => (
|
||||||
|
<Step key={label}>
|
||||||
|
<StepLabel>{label}</StepLabel>
|
||||||
|
</Step>
|
||||||
|
))}
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{ getStepContent(activeStep) }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ loading && (
|
||||||
|
<div className='loading'>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BackButton = ({ click, disabled }) => (
|
||||||
|
<Button onClick={click} disabled={disabled}>Go Back</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErrorMessage = ({ error }) => {
|
||||||
|
return error && (
|
||||||
|
<div className='error'>{error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardsImported = ({ cards, sets }) => {
|
||||||
|
const items = [
|
||||||
|
(
|
||||||
|
<div>
|
||||||
|
<strong>Import finished: {cards.length} cards.</strong>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
( <div className='spacer' /> ),
|
||||||
|
|
||||||
|
...sets.map(set => (
|
||||||
|
<div>{set.name}: {set.cards.length} cards imported</div>
|
||||||
|
) )
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='card-import-list'>
|
||||||
|
<VirtualList
|
||||||
|
itemKey={(index) => index }
|
||||||
|
items={items}
|
||||||
|
size={15}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const propsMap = {
|
||||||
|
form: FormKey.CARD_IMPORT,
|
||||||
|
onClose: Function
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = () => ({
|
||||||
|
initialValues: {
|
||||||
|
cardDownloadUrl: 'https://www.mtgjson.com/api/v5/AllPrintings.json',
|
||||||
|
tokenDownloadUrl: 'https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(reduxForm(propsMap)(CardImportForm));
|
|
@ -1,3 +1,4 @@
|
||||||
|
export { default as CardImportForm } from './CardImportForm/CardImportForm';
|
||||||
export { default as ConnectForm } from './ConnectForm/ConnectForm';
|
export { default as ConnectForm } from './ConnectForm/ConnectForm';
|
||||||
export { default as RegisterForm } from './RegisterForm/RegisterForm';
|
export { default as RegisterForm } from './RegisterForm/RegisterForm';
|
||||||
export { default as SearchForm } from './SearchForm/SearchForm';
|
export { default as SearchForm } from './SearchForm/SearchForm';
|
||||||
|
|
104
webclient/src/services/CardImporterService.ts
Normal file
104
webclient/src/services/CardImporterService.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// Fetch and parse card sets
|
||||||
|
|
||||||
|
class CardImporterService {
|
||||||
|
importCards(url): Promise<any> {
|
||||||
|
const error = 'Card import must be in valid MTG JSON format';
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(response => {
|
||||||
|
if (response.headers.get('Content-Type') !== 'application/json') {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
try {
|
||||||
|
const sortedSets = Object.keys(json.data)
|
||||||
|
.map(key => json.data[key])
|
||||||
|
.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
|
||||||
|
|
||||||
|
const sets = sortedSets.map(({ cards, tokens, ...set}) => ({
|
||||||
|
...set,
|
||||||
|
cards: cards.map(({ name }) => name),
|
||||||
|
tokens: tokens.map(({ name }) => name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unsortedCards = sortedSets.reduce((acc, set) => {
|
||||||
|
set.cards.forEach(card => acc[card.name] = card);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const cards = Object.keys(unsortedCards)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map(key => unsortedCards[key]);
|
||||||
|
|
||||||
|
return { cards, sets };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
importTokens(url): Promise<any> {
|
||||||
|
const error = 'Token import must be in valid MTG XML format';
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text()
|
||||||
|
})
|
||||||
|
.then((xmlString) => {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const dom = parser.parseFromString(xmlString, "application/xml");
|
||||||
|
|
||||||
|
const tokens = Array.from(dom.querySelectorAll('card')).map(
|
||||||
|
(tokenElement) => this.parseXmlAttributes(tokenElement)
|
||||||
|
);
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseXmlAttributes(dom: Element) {
|
||||||
|
return Array.from(dom.children).reduce((attributes, child) => {
|
||||||
|
const value = child.children.length ? this.parseXmlAttributes(child) : child.innerHTML;
|
||||||
|
|
||||||
|
let parsedAttributes = { value };
|
||||||
|
|
||||||
|
if (child.attributes.length) {
|
||||||
|
const childAttributes = Array.from(child.attributes).reduce((acc, { name, value }) => {
|
||||||
|
acc[name] = value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
parsedAttributes = {
|
||||||
|
...parsedAttributes,
|
||||||
|
...childAttributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// @TODO: clean this up and normalize what i'm returning
|
||||||
|
if (attributes[child.tagName]) {
|
||||||
|
if (Array.isArray(attributes[child.tagName])) {
|
||||||
|
attributes[child.tagName].push(parsedAttributes)
|
||||||
|
} else {
|
||||||
|
attributes[child.tagName] = [ parsedAttributes ];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attributes[child.tagName] = parsedAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cardImporterService = new CardImporterService();
|
19
webclient/src/services/DexieDTOs/CardDTO.ts
Normal file
19
webclient/src/services/DexieDTOs/CardDTO.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Card } from 'types';
|
||||||
|
|
||||||
|
import { dexieService } from '../DexieService';
|
||||||
|
|
||||||
|
export class CardDTO extends Card {
|
||||||
|
save() {
|
||||||
|
return dexieService.cards.put(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(name) {
|
||||||
|
return dexieService.cards.where('name').equalsIgnoreCase(name).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bulkAdd(cards: CardDTO[]): Promise<any> {
|
||||||
|
return dexieService.cards.bulkPut(cards);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dexieService.cards.mapToClass(CardDTO);
|
19
webclient/src/services/DexieDTOs/SetDTO.ts
Normal file
19
webclient/src/services/DexieDTOs/SetDTO.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Set } from 'types';
|
||||||
|
|
||||||
|
import { dexieService } from '../DexieService';
|
||||||
|
|
||||||
|
export class SetDTO extends Set {
|
||||||
|
save() {
|
||||||
|
return dexieService.sets.put(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(name) {
|
||||||
|
return dexieService.sets.where('name').equalsIgnoreCase(name).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bulkAdd(sets: SetDTO[]): Promise<any> {
|
||||||
|
return dexieService.sets.bulkPut(sets);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dexieService.cards.mapToClass(SetDTO);
|
19
webclient/src/services/DexieDTOs/TokenDTO.ts
Normal file
19
webclient/src/services/DexieDTOs/TokenDTO.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Token } from 'types';
|
||||||
|
|
||||||
|
import { dexieService } from '../DexieService';
|
||||||
|
|
||||||
|
export class TokenDTO extends Token {
|
||||||
|
save() {
|
||||||
|
return dexieService.tokens.put(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(name) {
|
||||||
|
return dexieService.tokens.where('name.value').equalsIgnoreCase(name).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bulkAdd(tokens: TokenDTO[]): Promise<any> {
|
||||||
|
return dexieService.tokens.bulkPut(tokens);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dexieService.tokens.mapToClass(TokenDTO);
|
3
webclient/src/services/DexieDTOs/index.ts
Normal file
3
webclient/src/services/DexieDTOs/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './CardDTO';
|
||||||
|
export * from './SetDTO';
|
||||||
|
export * from './TokenDTO';
|
35
webclient/src/services/DexieService.ts
Normal file
35
webclient/src/services/DexieService.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import Dexie from 'dexie';
|
||||||
|
|
||||||
|
enum Stores {
|
||||||
|
CARDS = 'cards',
|
||||||
|
SETS = 'sets',
|
||||||
|
TOKENS = 'tokens',
|
||||||
|
}
|
||||||
|
|
||||||
|
const StoreKeyIndexes = {
|
||||||
|
[Stores.CARDS]: "name",
|
||||||
|
[Stores.SETS]: "code",
|
||||||
|
[Stores.TOKENS]: "name.value",
|
||||||
|
};
|
||||||
|
|
||||||
|
class DexieService {
|
||||||
|
private db: Dexie = new Dexie('Webatrice');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.db.version(1).stores(StoreKeyIndexes);
|
||||||
|
}
|
||||||
|
|
||||||
|
get cards() {
|
||||||
|
return this.db.table(Stores.CARDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
get sets() {
|
||||||
|
return this.db.table(Stores.SETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
get tokens() {
|
||||||
|
return this.db.table(Stores.TOKENS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dexieService = new DexieService();
|
2
webclient/src/services/index.ts
Normal file
2
webclient/src/services/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./CardImporterService";
|
||||||
|
export * from './DexieDTOs';
|
93
webclient/src/types/cards.ts
Normal file
93
webclient/src/types/cards.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
export class Card {
|
||||||
|
artist: string;
|
||||||
|
availability: string[];
|
||||||
|
borderColor: string;
|
||||||
|
colorIdentity: string[];
|
||||||
|
colors: string[];
|
||||||
|
convertedManaCost: number;
|
||||||
|
edhrecRank: number;
|
||||||
|
flavorText: string;
|
||||||
|
identifiers: {
|
||||||
|
cardKingdomId: string;
|
||||||
|
mcmId: string;
|
||||||
|
mcmMetaId: string;
|
||||||
|
mtgjsonV4Id: string;
|
||||||
|
multiverseId: string;
|
||||||
|
scryfallId: string;
|
||||||
|
scryfallIllustrationId: string;
|
||||||
|
scryfallOracleId: string;
|
||||||
|
tcgplayerProductId: string;
|
||||||
|
};
|
||||||
|
isOnlineOnly: boolean;
|
||||||
|
layout: string;
|
||||||
|
legalities: {
|
||||||
|
brawl: string;
|
||||||
|
commander: string;
|
||||||
|
duel: string;
|
||||||
|
future: string;
|
||||||
|
gladiator: string;
|
||||||
|
historic: string;
|
||||||
|
legacy: string;
|
||||||
|
modern: string;
|
||||||
|
pauper: string;
|
||||||
|
penny: string;
|
||||||
|
pioneer: string;
|
||||||
|
premodern: string;
|
||||||
|
standard: string;
|
||||||
|
vintage: string;
|
||||||
|
};
|
||||||
|
manaCost: string;
|
||||||
|
name: string;
|
||||||
|
originalText: string;
|
||||||
|
originalType: string;
|
||||||
|
power: string;
|
||||||
|
printings: string[];
|
||||||
|
rarity: string;
|
||||||
|
rulings: {
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
side: string;
|
||||||
|
setCode: string;
|
||||||
|
subtypes: string[];
|
||||||
|
supertypes: string[];
|
||||||
|
text: string;
|
||||||
|
toughness: string;
|
||||||
|
type: string;
|
||||||
|
types: string[];
|
||||||
|
uuid: string;
|
||||||
|
variations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Set {
|
||||||
|
baseSetSize: number;
|
||||||
|
block: string;
|
||||||
|
cards: string[];
|
||||||
|
code: string;
|
||||||
|
isOnlineOnly: boolean;
|
||||||
|
name: string;
|
||||||
|
releaseDate: string;
|
||||||
|
totalSetSize: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Token {
|
||||||
|
name: { value: string };
|
||||||
|
prop: {
|
||||||
|
value: {
|
||||||
|
cmc: { value: string; };
|
||||||
|
colors: { value: string; };
|
||||||
|
maintype: { value: string; };
|
||||||
|
pt: { value: string; };
|
||||||
|
type: { value: string; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
related: { value: string; }[];
|
||||||
|
'reverse-related': { value: string; }[];
|
||||||
|
set: {
|
||||||
|
value: string;
|
||||||
|
picURL: string;
|
||||||
|
}[];
|
||||||
|
tablerow: { value: string; };
|
||||||
|
text: { value: string; };
|
||||||
|
}
|
84
webclient/src/types/constants.spec.ts
Normal file
84
webclient/src/types/constants.spec.ts
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
import {
|
||||||
|
URL_REGEX,
|
||||||
|
MESSAGE_SENDER_REGEX,
|
||||||
|
MENTION_REGEX,
|
||||||
|
CARD_CALLOUT_REGEX,
|
||||||
|
CALLOUT_BOUNDARY_REGEX,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
describe('RegEx', () => {
|
||||||
|
describe('URL_REGEX', () => {
|
||||||
|
it('should match and capture whole url in main capture group', () => {
|
||||||
|
const test = [
|
||||||
|
'http://example.com',
|
||||||
|
'https://example.com',
|
||||||
|
'https://www.example.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.forEach(str => {
|
||||||
|
const match = str.match(URL_REGEX);
|
||||||
|
|
||||||
|
expect(match).toBeDefined();
|
||||||
|
expect(match[0]).toBe(str);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match bad urls', () => {
|
||||||
|
const test = [
|
||||||
|
'htt://example.com',
|
||||||
|
'https:/example.com',
|
||||||
|
'https//www.example.com',
|
||||||
|
'www.example.com',
|
||||||
|
'example.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.forEach(str =>
|
||||||
|
expect(str.match(URL_REGEX)).toBe(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MESSAGE_SENDER_REGEX', () => {
|
||||||
|
it('should match and capture sender name in second capture group', () => {
|
||||||
|
const sender = 'sender';
|
||||||
|
const match = `${sender}: message`.match(MESSAGE_SENDER_REGEX);
|
||||||
|
|
||||||
|
expect(match).toBeDefined();
|
||||||
|
expect(match[1]).toBe(sender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match if spaces before :', () => {
|
||||||
|
const test = [
|
||||||
|
' sender: message',
|
||||||
|
'sender : message',
|
||||||
|
' sender : message',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.forEach(str =>
|
||||||
|
expect(str.match(URL_REGEX)).toBe(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MENTION_REGEX', () => {
|
||||||
|
it('should match and capture user mentions in second capture group', () => {
|
||||||
|
expect('@mention'.match(MENTION_REGEX)[0]).toBe('@mention');
|
||||||
|
expect('@mention '.match(MENTION_REGEX)[0]).toBe('@mention');
|
||||||
|
expect(' @mention'.match(MENTION_REGEX)[0]).toBe(' @mention');
|
||||||
|
expect(' @mention '.match(MENTION_REGEX)[0]).toBe(' @mention');
|
||||||
|
expect('leading @mention'.match(MENTION_REGEX)[0]).toBe(' @mention');
|
||||||
|
expect('leading @mention trailing'.match(MENTION_REGEX)[0]).toBe(' @mention');
|
||||||
|
expect('@mention trailing'.match(MENTION_REGEX)[0]).toBe('@mention');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match preceded by character', () => {
|
||||||
|
const test = [
|
||||||
|
'leading@mention',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.forEach(str =>
|
||||||
|
expect(str.match(MENTION_REGEX)).toBe(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
6
webclient/src/types/constants.ts
Normal file
6
webclient/src/types/constants.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
|
export const URL_REGEX = /(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b(?:[-a-zA-Z0-9@:%_\+.~#?&//=]*))/g;
|
||||||
|
export const MESSAGE_SENDER_REGEX = /(^[^:\s]+):/;
|
||||||
|
export const MENTION_REGEX = /(^|\s)(@\w+)/g;
|
||||||
|
export const CARD_CALLOUT_REGEX = /(\[\[[^\]]+\]\])/g;
|
||||||
|
export const CALLOUT_BOUNDARY_REGEX = /(\[\[|\]\])/g;
|
|
@ -1,6 +1,7 @@
|
||||||
export enum FormKey {
|
export enum FormKey {
|
||||||
ADD_TO_BUDDIES = "ADD_TO_BUDDIES",
|
ADD_TO_BUDDIES = "ADD_TO_BUDDIES",
|
||||||
ADD_TO_IGNORE = "ADD_TO_IGNORE",
|
ADD_TO_IGNORE = "ADD_TO_IGNORE",
|
||||||
|
CARD_IMPORT = "CARD_IMPORT",
|
||||||
CONNECT = "CONNECT",
|
CONNECT = "CONNECT",
|
||||||
REGISTER = "REGISTER",
|
REGISTER = "REGISTER",
|
||||||
SEARCH_LOGS = "SEARCH_LOGS",
|
SEARCH_LOGS = "SEARCH_LOGS",
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
export * from "./cards";
|
||||||
|
export * from "./constants";
|
||||||
export * from "./game";
|
export * from "./game";
|
||||||
export * from "./room";
|
export * from "./room";
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
|
|
|
@ -22,7 +22,7 @@ export enum KnownHost {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KnownHosts = {
|
export const KnownHosts = {
|
||||||
[KnownHost.ROOSTER]: { port: 443, host: 'server.cockatrice.us', },
|
[KnownHost.ROOSTER]: { port: 4748, host: 'server.cockatrice.us', },
|
||||||
[KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice'},
|
[KnownHost.TETRARCH]: { port: 443, host: 'mtg.tetrarch.co/servatrice'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,8 +31,12 @@ export default class RoomService {
|
||||||
const game = gameList[0];
|
const game = gameList[0];
|
||||||
|
|
||||||
if (!game.gameType) {
|
if (!game.gameType) {
|
||||||
const { gametypeMap } = RoomsSelectors.getRoom(store.getState(), roomId);
|
const room = RoomsSelectors.getRoom(store.getState(), roomId);
|
||||||
NormalizeService.normalizeGameObject(game, gametypeMap);
|
|
||||||
|
if (room) {
|
||||||
|
const { gametypeMap } = room;
|
||||||
|
NormalizeService.normalizeGameObject(game, gametypeMap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RoomsDispatch.updateGames(roomId, gameList);
|
RoomsDispatch.updateGames(roomId, gameList);
|
||||||
|
|
Loading…
Reference in a new issue