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

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

@ -32,4 +32,4 @@ const Row = ({ data, index, style }) => (
</div>
);
export default VirtualList;
export default VirtualList;

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