diff --git a/webclient/package-lock.json b/webclient/package-lock.json index 7487e9b8..54df47d3 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -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", diff --git a/webclient/package.json b/webclient/package.json index 1ac81cff..e87445b1 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -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" } } diff --git a/webclient/src/components/Card/Card.css b/webclient/src/components/Card/Card.css new file mode 100644 index 00000000..97661863 --- /dev/null +++ b/webclient/src/components/Card/Card.css @@ -0,0 +1,4 @@ +.card { + width: 100%; + height: 100%; +} diff --git a/webclient/src/components/Card/Card.tsx b/webclient/src/components/Card/Card.tsx new file mode 100644 index 00000000..f89622c3 --- /dev/null +++ b/webclient/src/components/Card/Card.tsx @@ -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 && ( + {card?.name} + ); +} + +export default Card; diff --git a/webclient/src/components/CardDetails/CardDetails.css b/webclient/src/components/CardDetails/CardDetails.css new file mode 100644 index 00000000..7009049d --- /dev/null +++ b/webclient/src/components/CardDetails/CardDetails.css @@ -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; +} diff --git a/webclient/src/components/CardDetails/CardDetails.tsx b/webclient/src/components/CardDetails/CardDetails.tsx new file mode 100644 index 00000000..84196fda --- /dev/null +++ b/webclient/src/components/CardDetails/CardDetails.tsx @@ -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 ( +
+
+ +
+ + { + card && ( +
+
+
+ Name: + {card.name} +
+ + { + (!card.power && !card.toughness) ? null : ( +
+ P/T: + {card.power || 0}/{card.toughness || 0} +
+ ) + } + + { + !card.manaCost ? null : ( +
+ Cost: + {card.manaCost.replace(/\{|\}/g, '')} +
+ ) + } + + { + !card.convertedManaCost ? null : ( +
+ CMC: + {card.convertedManaCost} +
+ ) + } + + { + !card.colorIdentity?.length ? null : ( +
+ Identity: + {card.colorIdentity.join('')} +
+ ) + } + + { + !card.colors?.length ? null : ( +
+ Color(s): + {card.colors.join('')} +
+ ) + } + + { + !card.types?.length ? null : ( +
+ Main Type: + {card.types.join(', ')} +
+ ) + } + + { + !card.type ? null : ( +
+ Type: + {card.type} +
+ ) + } + + { + !card.side ? null : ( +
+ Side: + {card.side} +
+ ) + } + + { + !card.layout ? null : ( +
+ Layout: + {card.layout} +
+ ) + } +
+ +
+
+ {card.text?.trim()} +
+ +
+ {card.flavorText?.trim()} +
+
+
+ ) + } +
+ ); +} + +export default CardDetails; diff --git a/webclient/src/components/CardImportDialog/CardImportDialog.css b/webclient/src/components/CardImportDialog/CardImportDialog.css new file mode 100644 index 00000000..debc4819 --- /dev/null +++ b/webclient/src/components/CardImportDialog/CardImportDialog.css @@ -0,0 +1,5 @@ +.dialog-title { + display: flex; + justify-content: space-between; + align-items: center; +} \ No newline at end of file diff --git a/webclient/src/components/CardImportDialog/CardImportDialog.tsx b/webclient/src/components/CardImportDialog/CardImportDialog.tsx new file mode 100644 index 00000000..52b60d24 --- /dev/null +++ b/webclient/src/components/CardImportDialog/CardImportDialog.tsx @@ -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 ( + + + Import Cards + + {handleOnClose ? ( + + + + ) : null} + + + + + + ); +}; + +export default CardImportDialog; \ No newline at end of file diff --git a/webclient/src/components/Header/Header.tsx b/webclient/src/components/Header/Header.tsx index 4302184b..84e2ce81 100644 --- a/webclient/src/components/Header/Header.tsx +++ b/webclient/src/components/Header/Header.tsx @@ -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 { state: HeaderState; options: string[] = [ @@ -29,12 +31,17 @@ class Header extends Component { 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 { 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 { {option} ))} + + this.openImportCardWizard()}> + Import Cards + @@ -163,6 +183,11 @@ class Header extends Component { ) } + + ) } @@ -177,7 +202,8 @@ interface HeaderProps { } interface HeaderState { - anchorEl: Element + anchorEl: Element; + showCardImportDialog: boolean; } const mapStateToProps = state => ({ diff --git a/webclient/src/components/Message/CardCallout.css b/webclient/src/components/Message/CardCallout.css new file mode 100644 index 00000000..0011b238 --- /dev/null +++ b/webclient/src/components/Message/CardCallout.css @@ -0,0 +1,4 @@ +.callout { + font-weight: bold; + color: green; +} diff --git a/webclient/src/components/Message/CardCallout.tsx b/webclient/src/components/Message/CardCallout.tsx new file mode 100644 index 00000000..61d023f0 --- /dev/null +++ b/webclient/src/components/Message/CardCallout.tsx @@ -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(null); + const [token, setToken] = useState(null); + const [anchorEl, setAnchorEl] = useState(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 ( + + {card?.name || token?.name?.value || name} + + { + (card || token) && ( + +
+ { card && ( ) } + { token && ( ) } +
+
+ ) + } +
+ ); +}; + +export default CardCallout; diff --git a/webclient/src/components/Message/Message.css b/webclient/src/components/Message/Message.css new file mode 100644 index 00000000..cbb0df2a --- /dev/null +++ b/webclient/src/components/Message/Message.css @@ -0,0 +1,3 @@ +.link { + color: blue; +} diff --git a/webclient/src/components/Message/Message.tsx b/webclient/src/components/Message/Message.tsx new file mode 100644 index 00000000..e041f717 --- /dev/null +++ b/webclient/src/components/Message/Message.tsx @@ -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 } }) => ( +
+
+ +
+
+); + +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 ( +
+ { name && ( : ) } + { messageChunks } +
+ ); +}; + +const PlayerLink = ({ name, label = name }) => ( + + {label} + +); + +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 ( ); + } + + 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 ( {urlChunk} ); + } + + 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 ( ); + } + + return mentionChunk; + }); +} + +export default Message; diff --git a/webclient/src/components/Token/Token.css b/webclient/src/components/Token/Token.css new file mode 100644 index 00000000..08f18b87 --- /dev/null +++ b/webclient/src/components/Token/Token.css @@ -0,0 +1,4 @@ +.token { + width: 100%; + height: 100%; +} diff --git a/webclient/src/components/Token/Token.tsx b/webclient/src/components/Token/Token.tsx new file mode 100644 index 00000000..29b39ecc --- /dev/null +++ b/webclient/src/components/Token/Token.tsx @@ -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 && ( + {token?.name?.value} + ); +} + +export default Token; diff --git a/webclient/src/components/TokenDetails/TokenDetails.css b/webclient/src/components/TokenDetails/TokenDetails.css new file mode 100644 index 00000000..3c326783 --- /dev/null +++ b/webclient/src/components/TokenDetails/TokenDetails.css @@ -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; +} diff --git a/webclient/src/components/TokenDetails/TokenDetails.tsx b/webclient/src/components/TokenDetails/TokenDetails.tsx new file mode 100644 index 00000000..f3d8aed9 --- /dev/null +++ b/webclient/src/components/TokenDetails/TokenDetails.tsx @@ -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 ( +
+
+ +
+ + { + token && ( +
+
+
+ Name: + {token.name?.value} +
+ + { + (!props.pt?.value) ? null : ( +
+ P/T: + {props.pt.value} +
+ ) + } + + { + !props.colors?.value ? null : ( +
+ Color(s): + {props.colors.value} +
+ ) + } + + { + !props.maintype?.value ? null : ( +
+ Main Type: + {props.maintype.value} +
+ ) + } + + { + !props.type?.value ? null : ( +
+ Type: + {props.type.value} +
+ ) + } +
+ + { + !token.text?.value ? null : ( +
+
+ {token.text.value} +
+
+ ) + } +
+ ) + } + +
+ ); +} + +export default TokenDetails; diff --git a/webclient/src/components/VirtualList/VirtualList.tsx b/webclient/src/components/VirtualList/VirtualList.tsx index 767da6a6..e40ef712 100644 --- a/webclient/src/components/VirtualList/VirtualList.tsx +++ b/webclient/src/components/VirtualList/VirtualList.tsx @@ -32,4 +32,4 @@ const Row = ({ data, index, style }) => ( ); -export default VirtualList; \ No newline at end of file +export default VirtualList; diff --git a/webclient/src/components/index.ts b/webclient/src/components/index.ts index 755e5bb2..4fcf62bb 100644 --- a/webclient/src/components/index.ts +++ b/webclient/src/components/index.ts @@ -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'; diff --git a/webclient/src/containers/Room/Messages.css b/webclient/src/containers/Room/Messages.css index f14cec6e..731002cb 100644 --- a/webclient/src/containers/Room/Messages.css +++ b/webclient/src/containers/Room/Messages.css @@ -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; } diff --git a/webclient/src/containers/Room/Messages.tsx b/webclient/src/containers/Room/Messages.tsx index 47b6d2ea..546a0f91 100644 --- a/webclient/src/containers/Room/Messages.tsx +++ b/webclient/src/containers/Room/Messages.tsx @@ -1,31 +1,20 @@ // eslint-disable-next-line import React from "react"; +import { Message } from 'components'; + import "./Messages.css"; const Messages = ({ messages }) => (
{ - messages && messages.map(({ message, messageType, timeOf, timeReceived }) => ( -
-
{ParsedMessage(message)}
+ messages && messages.map((message, index) => ( +
+
) ) }
); -const ParsedMessage = (message) => { - const name = message.match("^[^:]+:"); - - if (name && name.length) { - message = message.slice(name[0].length, message.length); - } - - return
- {name} - {message} -
-}; - -export default Messages; \ No newline at end of file +export default Messages; diff --git a/webclient/src/containers/Room/OpenGames.css b/webclient/src/containers/Room/OpenGames.css new file mode 100644 index 00000000..623ab47f --- /dev/null +++ b/webclient/src/containers/Room/OpenGames.css @@ -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%; +} diff --git a/webclient/src/containers/Room/OpenGames.tsx b/webclient/src/containers/Room/OpenGames.tsx new file mode 100644 index 00000000..cb72c753 --- /dev/null +++ b/webclient/src/containers/Room/OpenGames.tsx @@ -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 { + 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 ( +
+ + + + { _.map(this.headerCells, ({ label, field }) => { + const active = field === sortBy.field; + const order = sortBy.order.toLowerCase(); + const sortDirection = active ? order : false; + + return ( + + {!field ? label : ( + this.handleSort(field)} + > + {label} + + )} + + ); + })} + + + + { _.map(games, ({ description, gameId, gameType, creatorInfo, maxPlayers, playerCount, spectatorsCount, startTime }) => ( + + {startTime} + + +
+ {description} +
+
+
+ + + + {gameType} + ? + {`${playerCount}/${maxPlayers}`} + {spectatorsCount} +
+ ))} +
+
+
+ ); + } +} + +interface OpenGamesProps { + room: any; + sortBy: any; +} + +const mapStateToProps = state => ({ + sortBy: RoomsSelectors.getSortGamesBy(state) +}); + +export default connect(mapStateToProps)(OpenGames); diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index f37b7dad..0e026627 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -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 { top={( - + )} diff --git a/webclient/src/forms/CardImportForm/CardImportForm.css b/webclient/src/forms/CardImportForm/CardImportForm.css new file mode 100644 index 00000000..56ac3e16 --- /dev/null +++ b/webclient/src/forms/CardImportForm/CardImportForm.css @@ -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%); +} diff --git a/webclient/src/forms/CardImportForm/CardImportForm.tsx b/webclient/src/forms/CardImportForm/CardImportForm.tsx new file mode 100644 index 00000000..08a59f3e --- /dev/null +++ b/webclient/src/forms/CardImportForm/CardImportForm.tsx @@ -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 ( +
+
+ +
+ +
+ +
+ +
+ +
+
+ ); + + case 1: return ( +
+
+ +
+ +
+ + +
+ +
+ +
+
+ ); + + case 2: return ( +
+
+ +
+ +
+ + +
+ +
+ +
+
+ ); + + case 3: return ( +
+
Finished!
+ +
+ + +
+
+ ); + } + }; + + return ( +
+ + {steps.map((label) => ( + + {label} + + ))} + + +
+ { getStepContent(activeStep) } +
+ + { loading && ( +
+ +
+ ) } +
+ ); +} + +const BackButton = ({ click, disabled }) => ( + +); + +const ErrorMessage = ({ error }) => { + return error && ( +
{error}
+ ); +} + +const CardsImported = ({ cards, sets }) => { + const items = [ + ( +
+ Import finished: {cards.length} cards. +
+ ), + + (
), + + ...sets.map(set => ( +
{set.name}: {set.cards.length} cards imported
+ ) ) + ]; + + return ( +
+ index } + items={items} + size={15} + /> +
+ ); +}; + +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)); diff --git a/webclient/src/forms/index.ts b/webclient/src/forms/index.ts index d01ceca0..72fa688e 100644 --- a/webclient/src/forms/index.ts +++ b/webclient/src/forms/index.ts @@ -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'; diff --git a/webclient/src/services/CardImporterService.ts b/webclient/src/services/CardImporterService.ts new file mode 100644 index 00000000..e456c8ba --- /dev/null +++ b/webclient/src/services/CardImporterService.ts @@ -0,0 +1,104 @@ +// Fetch and parse card sets + +class CardImporterService { + importCards(url): Promise { + 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 { + 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(); diff --git a/webclient/src/services/DexieDTOs/CardDTO.ts b/webclient/src/services/DexieDTOs/CardDTO.ts new file mode 100644 index 00000000..a060aedb --- /dev/null +++ b/webclient/src/services/DexieDTOs/CardDTO.ts @@ -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 { + return dexieService.cards.bulkPut(cards); + } +}; + +dexieService.cards.mapToClass(CardDTO); diff --git a/webclient/src/services/DexieDTOs/SetDTO.ts b/webclient/src/services/DexieDTOs/SetDTO.ts new file mode 100644 index 00000000..616e8d56 --- /dev/null +++ b/webclient/src/services/DexieDTOs/SetDTO.ts @@ -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 { + return dexieService.sets.bulkPut(sets); + } +}; + +dexieService.cards.mapToClass(SetDTO); diff --git a/webclient/src/services/DexieDTOs/TokenDTO.ts b/webclient/src/services/DexieDTOs/TokenDTO.ts new file mode 100644 index 00000000..35d53770 --- /dev/null +++ b/webclient/src/services/DexieDTOs/TokenDTO.ts @@ -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 { + return dexieService.tokens.bulkPut(tokens); + } +}; + +dexieService.tokens.mapToClass(TokenDTO); diff --git a/webclient/src/services/DexieDTOs/index.ts b/webclient/src/services/DexieDTOs/index.ts new file mode 100644 index 00000000..2ec93016 --- /dev/null +++ b/webclient/src/services/DexieDTOs/index.ts @@ -0,0 +1,3 @@ +export * from './CardDTO'; +export * from './SetDTO'; +export * from './TokenDTO'; diff --git a/webclient/src/services/DexieService.ts b/webclient/src/services/DexieService.ts new file mode 100644 index 00000000..2429481b --- /dev/null +++ b/webclient/src/services/DexieService.ts @@ -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(); diff --git a/webclient/src/services/index.ts b/webclient/src/services/index.ts new file mode 100644 index 00000000..e5e2a150 --- /dev/null +++ b/webclient/src/services/index.ts @@ -0,0 +1,2 @@ +export * from "./CardImporterService"; +export * from './DexieDTOs'; diff --git a/webclient/src/types/cards.ts b/webclient/src/types/cards.ts new file mode 100644 index 00000000..4e103d97 --- /dev/null +++ b/webclient/src/types/cards.ts @@ -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; }; +} diff --git a/webclient/src/types/constants.spec.ts b/webclient/src/types/constants.spec.ts new file mode 100644 index 00000000..f95a16ea --- /dev/null +++ b/webclient/src/types/constants.spec.ts @@ -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) + ); + }); + }); +}); diff --git a/webclient/src/types/constants.ts b/webclient/src/types/constants.ts new file mode 100644 index 00000000..7341a22b --- /dev/null +++ b/webclient/src/types/constants.ts @@ -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; diff --git a/webclient/src/types/forms.ts b/webclient/src/types/forms.ts index 3898b3e9..753885ea 100644 --- a/webclient/src/types/forms.ts +++ b/webclient/src/types/forms.ts @@ -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", diff --git a/webclient/src/types/index.ts b/webclient/src/types/index.ts index c4aee590..9403d6f8 100644 --- a/webclient/src/types/index.ts +++ b/webclient/src/types/index.ts @@ -1,3 +1,5 @@ +export * from "./cards"; +export * from "./constants"; export * from "./game"; export * from "./room"; export * from "./server"; diff --git a/webclient/src/types/server.tsx b/webclient/src/types/server.tsx index ba8ebb30..91819737 100644 --- a/webclient/src/types/server.tsx +++ b/webclient/src/types/server.tsx @@ -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'}, } diff --git a/webclient/src/websocket/persistence/RoomService.tsx b/webclient/src/websocket/persistence/RoomService.tsx index 1f2a33eb..b464b59b 100644 --- a/webclient/src/websocket/persistence/RoomService.tsx +++ b/webclient/src/websocket/persistence/RoomService.tsx @@ -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);