Webatrice: card import wizard (#4397)

This commit is contained in:
Jeremy Letto 2021-10-14 20:42:35 -05:00 committed by GitHub
parent dde0f568d9
commit 36e5a399d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1479 additions and 35 deletions

View file

@ -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",

View file

@ -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"
}
}

View file

@ -0,0 +1,4 @@
.card {
width: 100%;
height: 100%;
}

View 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;

View 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;
}

View 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;

View file

@ -0,0 +1,5 @@
.dialog-title {
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -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;

View file

@ -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 => ({

View file

@ -0,0 +1,4 @@
.callout {
font-weight: bold;
color: green;
}

View 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;

View file

@ -0,0 +1,3 @@
.link {
color: blue;
}

View 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;

View file

@ -0,0 +1,4 @@
.token {
width: 100%;
height: 100%;
}

View 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;

View 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;
}

View 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;

View file

@ -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';

View file

@ -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;
}

View file

@ -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;

View file

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

View file

@ -0,0 +1,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);

View file

@ -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>
)}

View 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%);
}

View 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));

View file

@ -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';

View 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();

View 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);

View 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);

View 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);

View file

@ -0,0 +1,3 @@
export * from './CardDTO';
export * from './SetDTO';
export * from './TokenDTO';

View 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();

View file

@ -0,0 +1,2 @@
export * from "./CardImporterService";
export * from './DexieDTOs';

View 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; };
}

View 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)
);
});
});
});

View 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;

View file

@ -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",

View file

@ -1,3 +1,5 @@
export * from "./cards";
export * from "./constants";
export * from "./game";
export * from "./room";
export * from "./server";

View file

@ -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'},
}

View file

@ -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);