import { Backdrop, Button, ButtonGroup, createStyles, Fade, Grid, IconButton, makeStyles, Modal, Paper, Slider, TextField, Theme, Typography, useTheme, } from '@material-ui/core'; import { green, orange } from '@material-ui/core/colors'; import { Add, ArrowBack, Delete, Link, Person, Search, Timer, TimerOff, Visibility, VisibilityOff, } from '@material-ui/icons'; import { ok as assertTrue } from 'assert'; import isArray from 'lodash/isArray'; import range from 'lodash/range'; import { DropzoneDialog } from 'material-ui-dropzone'; import * as React from 'react'; import isEqual from 'react-fast-compare'; import { Controller, useForm } from 'react-hook-form'; import { DeepReadonly } from 'ts-essentials'; import { isDefined, nameofFactory, noComplete } from '../common'; import { Board } from '../components/board'; import { ClipboardButton } from '../components/clipboard'; import { useServerTime } from '../hooks'; import { State, StatePlayer, StateTeams, StateTimer, StateWordList, WordPack } from '../protocol'; import { teamSpecs } from '../teams'; export interface Sender { reveal: (row: number, col: number) => void; newGame: () => void; endTurn: () => void; changeNickname: (nickname: string) => void; changeRole: (spymaster: boolean) => void; changeTeam: (team: number) => void; randomizeTeams: () => void; changePack: (num: number, enable: boolean) => void; changeTurnMode: (timed: boolean) => void; changeTurnTime: (seconds: number) => void; addPacks: (packs: { name: string; words: string[] }[]) => void; removePack: (num: number) => void; changeHideBomb: (HideBomb: boolean) => void; } const useCenterStyles = makeStyles((_theme: Theme) => createStyles({ blink: { animation: '$blinker 0.5s cubic-bezier(.5, 0, 1, 1) infinite alternate', }, '@keyframes blinker': { to: { opacity: 0, }, }, }) ); interface CenterTextProps { winner: number | undefined | null; timer: StateTimer | undefined | null; turn: number; myTurn: boolean; } const CenterText = ({ winner, timer, turn, myTurn }: DeepReadonly) => { const classes = useCenterStyles(); const [countdown, setCountdown] = React.useState(); const { now } = useServerTime(); const deadline = timer?.turnEnd; React.useEffect(() => { const updateCountdown = () => { if (isDefined(winner)) { setCountdown(undefined); return; } if (deadline === undefined) { if (countdown !== undefined) { setCountdown(undefined); } return; } const diff = deadline.getTime() - now(); const between = Math.floor(diff / 1000); if (between < 0) { if (countdown === 0) { return; } setCountdown(0); } else { setCountdown(between); } }; updateCountdown(); const interval = window.setInterval(() => { updateCountdown(); }, 200); return () => window.clearInterval(interval); }, [countdown, winner, deadline, now]); const centerText = React.useMemo(() => { const text = isDefined(winner) ? `${teamSpecs[winner].name} wins!` : myTurn ? 'Your turn' : `${teamSpecs[turn].name}'s turn`; if (!isDefined(countdown) || isDefined(winner)) { return text; } return `${text} [${countdown}s]`; }, [winner, turn, myTurn, countdown]); return (

{centerText}

); }; interface HeaderProps { send: Sender; myTurn: boolean; winner: number | undefined | null; spymaster: boolean; turn: number; wordsLeft: number[]; timer: StateTimer | undefined | null; } const Header = React.memo(function Header({ send, myTurn, winner, spymaster, turn, wordsLeft, timer, }: DeepReadonly) { return (

{wordsLeft.map((n, team) => { return ( {team !== 0 ? - : null} {n} ); })}

); }, isEqual); const sliderMarks = range(30, 301, 30).map((v) => ({ value: v })); interface TimerSliderProps { version: number; timer: StateTimer; onCommit: (value: number) => void; } interface TimerValue { version: number; turnTime: number; } const TimerSlider = ({ version, timer, onCommit }: TimerSliderProps) => { const [value, setValue] = React.useState({ version, turnTime: timer.turnTime }); React.useEffect(() => { if (version !== value.version) { setValue({ version, turnTime: timer.turnTime }); } }, [version, value.version, timer.turnTime]); const valueStr = React.useMemo(() => { const turnTime = value.turnTime; switch (turnTime) { case 30: return '30 seconds'; case 60: return '60 seconds'; default: if (turnTime % 60 === 0) { return `${turnTime / 60} minutes`; } return `${(turnTime / 60).toFixed(1)} minutes`; } }, [value.turnTime]); return ( <> Timer: {valueStr} { assertTrue(!isArray(v)); setValue({ version: value.version, turnTime: v }); }} onChangeCommitted={(_e, v) => { assertTrue(!isArray(v)); onCommit(v); }} /> ); }; const useChangeNicknameStyles = makeStyles((theme: Theme) => createStyles({ modal: { display: 'flex', alignItems: 'center', justifyContent: 'center', }, paper: { border: '2px solid #000', boxShadow: theme.shadows[5], padding: theme.spacing(2, 4, 3), maxWidth: '500px', }, label: { color: theme.palette.text.secondary + ' !important', }, }) ); interface ChangeNicknameFormData { nickname: string; } const ChangeNicknameButton = ({ send }: { send: Sender }) => { const classes = useChangeNicknameStyles(); const [open, setOpen] = React.useState(false); const handleOpen = () => setOpen(true); const handleClose = () => setOpen(false); const formName = React.useMemo(() => nameofFactory(), []); const { control, handleSubmit, errors } = useForm({}); const doSubmit = handleSubmit((data) => { handleClose(); send.changeNickname(data.nickname); }); return ( <>
); }; interface SidebarTeamsProps { send: Sender; teams: StateTeams; pTeam: number; playerID: string; } const SidebarTeams = React.memo(function SidebarTeams({ send, teams, pTeam, playerID, }: DeepReadonly) { const theme = useTheme(); const nameShade = theme.palette.type === 'dark' ? 400 : 600; return ( <>

Teams

{teams.map((team, i) => ( {team.map((member, j) => ( {member.spymaster ? `[${member.nickname}]` : member.nickname} ))} ))}
); }, isEqual); const useSidebarPacksStyles = makeStyles((_theme: Theme) => createStyles({ dropzone: { backgroundColor: 'initial', }, previewGrid: { width: '100%', }, }) ); interface SidebarPacksProps { send: Sender; lists: StateWordList[]; } const SidebarPacks = React.memo(function SidebarPacks({ send, lists }: DeepReadonly) { const classes = useSidebarPacksStyles(); const wordCount = React.useMemo( () => lists.reduce((curr, l) => { if (l.enabled) { return curr + l.count; } return curr; }, 0), [lists] ); const [uploadOpen, setUploadOpen] = React.useState(false); return ( <>

Packs

{wordCount} words in the selected packs.

{lists.map((pack, i) => (
{pack.custom && !pack.enabled ? ( send.removePack(i)}> ) : null}
))} {lists.length >= 10 ? null : ( <> setUploadOpen(false)} onSave={async (files) => { setUploadOpen(false); const packs: WordPack[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const name = file.name.substring(0, file.name.lastIndexOf('.')) || file.name; const words = (await file.text()) .split('\n') .map((word) => word.trim()) .filter((word) => word); if (words.length < 25) { continue; } packs.push({ name, words }); } if (packs.length) { send.addPacks(packs); } }} /> )}
); }, isEqual); interface SidebarProps { send: Sender; teams: StateTeams; lists: StateWordList[]; pTeam: number; playerID: string; version: number; timer: StateTimer | undefined | null; } const Sidebar = ({ send, teams, lists, pTeam, playerID, version, timer }: DeepReadonly) => { return ( <> {!isDefined(timer) ? null : (
)} ); }; const useFooterStyles = makeStyles((_theme: Theme) => createStyles({ root: { display: 'flex', justifyContent: 'space-between', alignContent: 'flex-start', flexWrap: 'wrap', }, left: { display: 'flex', alignContent: 'flex-start', flexWrap: 'wrap', }, leftButton: { marginBottom: '0.5rem', marginRight: '0.5rem', }, }) ); interface FooterProps { send: Sender; end: boolean; spymaster: boolean; hideBomb: boolean; hasTimer: boolean; } const Footer = React.memo(function Footer({ send, end, spymaster, hideBomb, hasTimer }: DeepReadonly) { const classes = useFooterStyles(); return (
); }, isEqual); const useCornerButtonsStyle = makeStyles((_theme: Theme) => createStyles({ wrapper: { position: 'absolute', top: 0, left: 0, margin: '0.5rem', }, button: { marginRight: '0.5rem', }, }) ); const CornerButtons = React.memo(function CornerButtons({ roomID, leave }: { roomID: string; leave: () => void }) { const classes = useCornerButtonsStyle(); return ( <>
} />
); }); const useStyles = makeStyles((theme: Theme) => createStyles({ root: { height: '100vh', display: 'flex', }, wrapper: { width: '100%', textAlign: 'center', paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), // Emulate the MUI Container component. maxWidth: `1560px`, // TODO: Surely this shouldn't be hardcoded. margin: 'auto', // marginRight: 'auto', display: 'grid', gridGap: theme.spacing(2), gridTemplateAreas: '"header" "board" "footer" "sidebar"', [theme.breakpoints.down('lg')]: { paddingTop: theme.spacing(5), }, [theme.breakpoints.up('lg')]: { gridTemplateColumns: '1fr 4fr 1fr', gridTemplateRows: '1fr auto 1fr', gridTemplateAreas: '". header ." "sidebar board ." ". footer ."', }, }, header: { gridArea: 'header', }, board: { gridArea: 'board', }, footer: { gridArea: 'footer', }, sidebar: { gridArea: 'sidebar', }, }) ); export interface GameViewProps { roomID: string; leave: () => void; send: Sender; state: State; pState: StatePlayer; pTeam: number; } export const GameView = ({ roomID, leave, send, state, pState, pTeam }: DeepReadonly) => { const classes = useStyles(); const end = isDefined(state.winner); const myTurn = state.turn === pTeam; return (
); };