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": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz",
|
||||
|
@ -12843,10 +12848,9 @@
|
|||
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
||||
},
|
||||
"typescript": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
|
||||
"integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==",
|
||||
"dev": true
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
|
||||
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA=="
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "3.4.10",
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.4",
|
||||
"@material-ui/icons": "^4.11.2",
|
||||
"dexie": "^3.0.3",
|
||||
"jquery": "^3.4.1",
|
||||
"lodash": "^4.17.15",
|
||||
"prop-types": "^15.7.2",
|
||||
|
@ -18,7 +19,8 @@
|
|||
"react-window": "^1.8.5",
|
||||
"redux": "^4.0.4",
|
||||
"redux-form": "^8.2.6",
|
||||
"redux-thunk": "^2.3.0"
|
||||
"redux-thunk": "^2.3.0",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "echo 'Copying shared files...' && ./copy_shared_files.sh",
|
||||
|
@ -57,7 +59,6 @@
|
|||
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
||||
"@types/react-window": "^1.8.2",
|
||||
"@types/redux": "^3.6.0",
|
||||
"@types/redux-form": "^8.2.0",
|
||||
"typescript": "3.6.4"
|
||||
"@types/redux-form": "^8.2.0"
|
||||
}
|
||||
}
|
||||
|
|
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 logo from "./logo.png";
|
||||
|
||||
import CardImportDialog from '../CardImportDialog/CardImportDialog';
|
||||
|
||||
class Header extends Component<HeaderProps> {
|
||||
state: HeaderState;
|
||||
options: string[] = [
|
||||
|
@ -29,12 +31,17 @@ class Header extends Component<HeaderProps> {
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { anchorEl: null };
|
||||
this.state = {
|
||||
anchorEl: null,
|
||||
showCardImportDialog: false,
|
||||
};
|
||||
|
||||
this.handleMenuOpen = this.handleMenuOpen.bind(this);
|
||||
this.handleMenuItemClick = this.handleMenuItemClick.bind(this);
|
||||
this.handleMenuClose = this.handleMenuClose.bind(this);
|
||||
this.leaveRoom = this.leaveRoom.bind(this);
|
||||
this.openImportCardWizard = this.openImportCardWizard.bind(this);
|
||||
this.closeImportCardWizard = this.closeImportCardWizard.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
@ -65,9 +72,18 @@ class Header extends Component<HeaderProps> {
|
|||
RoomsService.leaveRoom(roomId);
|
||||
};
|
||||
|
||||
openImportCardWizard() {
|
||||
this.setState({ showCardImportDialog: true });
|
||||
this.handleMenuClose();
|
||||
}
|
||||
|
||||
closeImportCardWizard() {
|
||||
this.setState({ showCardImportDialog: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { joinedRooms, state, user } = this.props;
|
||||
const { anchorEl } = this.state;
|
||||
const { anchorEl, showCardImportDialog } = this.state;
|
||||
|
||||
let options = [ ...this.options ];
|
||||
|
||||
|
@ -156,6 +172,10 @@ class Header extends Component<HeaderProps> {
|
|||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<MenuItem key='Import Cards' onClick={(event) => this.openImportCardWizard()}>
|
||||
Import Cards
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -163,6 +183,11 @@ class Header extends Component<HeaderProps> {
|
|||
</div>
|
||||
) }
|
||||
</Toolbar>
|
||||
|
||||
<CardImportDialog
|
||||
isOpen={showCardImportDialog}
|
||||
handleClose={this.closeImportCardWizard}
|
||||
></CardImportDialog>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
|
@ -177,7 +202,8 @@ interface HeaderProps {
|
|||
}
|
||||
|
||||
interface HeaderState {
|
||||
anchorEl: Element
|
||||
anchorEl: Element;
|
||||
showCardImportDialog: boolean;
|
||||
}
|
||||
|
||||
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;
|
|
@ -1,7 +1,10 @@
|
|||
// 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 InputField } from './InputField/InputField';
|
||||
export { default as InputAction } from './InputAction/InputAction';
|
||||
export { default as Message } from './Message/Message';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList';
|
||||
export { default as UserDisplay} from './UserDisplay/UserDisplay';
|
||||
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 ModGuard} from './Guard/ModGuard';
|
||||
|
||||
// Dialogs
|
||||
export { default as CardImportDialog} from './CardImportDialog/CardImportDialog';
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.message {
|
||||
.message-wrapper {
|
||||
padding: 5px 0;
|
||||
margin: 2px 0;
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.message:last-of-type {
|
||||
.message-wrapper:last-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
|
|
@ -1,31 +1,20 @@
|
|||
// eslint-disable-next-line
|
||||
import React from "react";
|
||||
|
||||
import { Message } from 'components';
|
||||
|
||||
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>
|
||||
messages && messages.map((message, index) => (
|
||||
<div className="message-wrapper" key={message.timeReceived}>
|
||||
<Message message={message} />
|
||||
</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;
|
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 { RouteEnum } from "types";
|
||||
|
||||
import Games from "./Games";
|
||||
import OpenGames from "./OpenGames";
|
||||
import Messages from "./Messages";
|
||||
import SayMessage from "./SayMessage";
|
||||
|
||||
|
@ -60,7 +60,7 @@ class Room extends Component<any> {
|
|||
|
||||
top={(
|
||||
<Paper className="room-view__games overflow-scroll">
|
||||
<Games room={room} />
|
||||
<OpenGames room={room} />
|
||||
</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 RegisterForm } from './RegisterForm/RegisterForm';
|
||||
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 {
|
||||
ADD_TO_BUDDIES = "ADD_TO_BUDDIES",
|
||||
ADD_TO_IGNORE = "ADD_TO_IGNORE",
|
||||
CARD_IMPORT = "CARD_IMPORT",
|
||||
CONNECT = "CONNECT",
|
||||
REGISTER = "REGISTER",
|
||||
SEARCH_LOGS = "SEARCH_LOGS",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export * from "./cards";
|
||||
export * from "./constants";
|
||||
export * from "./game";
|
||||
export * from "./room";
|
||||
export * from "./server";
|
||||
|
|
|
@ -22,7 +22,7 @@ export enum KnownHost {
|
|||
}
|
||||
|
||||
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'},
|
||||
}
|
||||
|
||||
|
|
|
@ -31,8 +31,12 @@ export default class RoomService {
|
|||
const game = gameList[0];
|
||||
|
||||
if (!game.gameType) {
|
||||
const { gametypeMap } = RoomsSelectors.getRoom(store.getState(), roomId);
|
||||
NormalizeService.normalizeGameObject(game, gametypeMap);
|
||||
const room = RoomsSelectors.getRoom(store.getState(), roomId);
|
||||
|
||||
if (room) {
|
||||
const { gametypeMap } = room;
|
||||
NormalizeService.normalizeGameObject(game, gametypeMap);
|
||||
}
|
||||
}
|
||||
|
||||
RoomsDispatch.updateGames(roomId, gameList);
|
||||
|
|
Loading…
Reference in a new issue