diff --git a/src/components/Chat/Chat.js b/src/components/Chat/Chat.js index 131c52d1..c4722d77 100644 --- a/src/components/Chat/Chat.js +++ b/src/components/Chat/Chat.js @@ -165,6 +165,35 @@ export default class Chat extends Component { ); } + renderMobileChat() { + return ( +
+
+
Chat
+
+ +
+
+
+ {this.renderChatHeader()} + {this.renderChatSubheader()} +
{ + if (el) { + el.scrollTop = el.scrollHeight; + } + }} + className="chat--messages" + > + {this.props.data.messages && + this.props.data.messages.map((message, i) =>
{this.renderMessage(message)}
)} +
+
+
{this.renderChatBar()}
+
+ ); + } + renderFencingOptions() { const fencingUrl = `/fencing/${this.props.gid}`; const normalUrl = `/beta/game/${this.props.gid}`; @@ -411,10 +440,15 @@ export default class Chat extends Component { render() { const messages = this.mergeMessages(this.props.data, this.props.opponentData); + + if (this.props.mobile && this.props.chatOpen) { + return this.renderMobileChat(); + } + return ( {this.renderToolbar()} -
+
{this.renderChatHeader()} {this.renderChatSubheader()}
{ + this.props.onSelectClue(direction, number); + if (this.props.onClose) { + this.props.onClose(); + } + }; + + renderClueSection(direction, clues = {}, clueLengths) { + const title = direction === 'across' ? 'Across' : 'Down'; + const {selected} = this.props; + const isSelectedDirection = selected.direction === direction; + + return ( +
+
{title}
+ {Object.keys(clues).map((number) => { + const isSelected = isSelectedDirection && selected.number === number; + const clueText = clues[number]; + const clueLength = clueLengths && clueLengths[direction] && clueLengths[direction][number]; + const lengthText = clueLength ? ` (${clueLength})` : ''; + + return ( +
this.handleClueClick(direction, number)} + > +
{number}
+
+ +
+
+ ); + })} +
+ ); + } + + render() { + const {clues, clueLengths, onClose} = this.props; + + return ( +
+
+
Clues
+
+ +
+
+
+ {this.renderClueSection('across', clues.across, clueLengths)} + {this.renderClueSection('down', clues.down, clueLengths)} +
+
+ ); + } +} diff --git a/src/components/Player/MobileGridControls.js b/src/components/Player/MobileGridControls.js index 054f9b22..c5b8a000 100644 --- a/src/components/Player/MobileGridControls.js +++ b/src/components/Player/MobileGridControls.js @@ -7,6 +7,7 @@ import {MdKeyboardArrowLeft, MdKeyboardArrowRight} from 'react-icons/md'; import classnames from 'classnames'; import _ from 'lodash'; import Clue from './ClueText'; +import ClueList from './ClueList'; import GridControls from './GridControls'; import GridObject from '../../lib/wrappers/GridWrapper'; import * as gameUtils from '../../lib/gameUtils'; @@ -21,6 +22,8 @@ export default class MobileGridControls extends GridControls { anchors: [], transform: {scale: 1, translateX: 0, translateY: 0}, dbgstr: undefined, + showClueList: false, + showKeyboard: true, }; this.prvInput = ''; this.inputRef = React.createRef(); @@ -407,6 +410,7 @@ export default class MobileGridControls extends GridControls { focusKeyboard() { this.inputRef.current.selectionStart = this.inputRef.current.selectionEnd = this.inputRef.current.value.length; this.inputRef.current.focus(); + this.setState({showKeyboard: true}); } keepFocus = () => { @@ -525,14 +529,82 @@ export default class MobileGridControls extends GridControls { ); } + toggleClueList = () => { + this.setState((state) => ({showClueList: !state.showClueList})); + }; + + handleSelectClue = (direction, number) => { + this.props.onSetDirection(direction); + const grid = this.grid; + const clueRoot = grid.getCellByNumber(number); + if (clueRoot) { + this.props.onSetSelected(clueRoot); + } + }; + + renderOnscreenKeyboard() { + if (!this.state.showKeyboard) return null; + + return ( +
+ {'QWERTYUIOP'.split('').map((letter) => ( + + ))} + {'ASDFGHJKL'.split('').map((letter) => ( + + ))} + {'ZXCVBNM'.split('').map((letter) => ( + + ))} + + +
+ ); + } + + handleKeyClick = (letter) => { + this.typeLetter(letter, false, {nextClueIfFilled: true}); + }; + + handleBackspaceClick = () => { + this.backspace(); + }; + + handleToggleDirection = () => { + this.flipDirection(); + }; + render() { + const {showClueList} = this.state; + return ( // eslint-disable-next-line react/no-string-refs
{this.renderClueBar()} - {this.renderGridContent()} +
{this.renderGridContent()}
{this.renderMobileInputs()} - {/* {this.renderMobileKeyboard()} */} + {this.renderOnscreenKeyboard()} + {showClueList && ( + + )} {this.props.enableDebug && (this.state.dbgstr || 'No message')}
); diff --git a/src/components/Player/css/clueList.css b/src/components/Player/css/clueList.css new file mode 100644 index 00000000..be46079d --- /dev/null +++ b/src/components/Player/css/clueList.css @@ -0,0 +1,13 @@ +/* Styles for clue list component */ +.mobile-grid-controls--clue-list-overlay .mobile-grid-controls--clue-list-title { + font-size: 18px; + font-weight: bold; +} + +.mobile-grid-controls--clue-list-content { + padding-bottom: 80px; /* Ensure bottom clues are visible when scrolling */ +} + +.mobile-grid-controls--clue-item-text { + flex: 1; +} diff --git a/src/components/Player/css/index.css b/src/components/Player/css/index.css index 7a0870dd..f7439711 100644 --- a/src/components/Player/css/index.css +++ b/src/components/Player/css/index.css @@ -91,18 +91,27 @@ .player--mobile--wrapper { display: flex; flex: 1; + flex-direction: column; + margin-top: 48px; /* Account for fixed toolbar */ + height: calc(100vh - 48px); + overflow: hidden; } .player--mobile { flex: 1; display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; align-items: center; + overflow-y: auto; + padding-bottom: 200px; /* Space for keyboard */ } .player--mobile--grid { display: flex; + width: 100%; + max-width: 600px; + margin: 0 auto; } .player--mobile--list-view { diff --git a/src/components/Player/css/mobileGridControls.css b/src/components/Player/css/mobileGridControls.css index 993d004f..45484986 100644 --- a/src/components/Player/css/mobileGridControls.css +++ b/src/components/Player/css/mobileGridControls.css @@ -84,3 +84,67 @@ display: flex; align-items: center; } + +/* Clue list overlay */ +.mobile-grid-controls--clue-list-overlay { + position: fixed; + top: 48px; /* below toolbar */ + left: 0; + width: 100%; + height: calc(100% - 48px); + background: white; + z-index: 10; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mobile-grid-controls--clue-list-header { + display: flex; + justify-content: space-between; + padding: 10px; + border-bottom: 1px solid #eee; + background: var(--main-blue-3); +} + +.mobile-grid-controls--clue-list-close { + font-size: 24px; + cursor: pointer; +} + +.mobile-grid-controls--clue-list-content { + flex: 1; + overflow-y: auto; + padding: 0 10px; +} + +.mobile-grid-controls--clue-section { + margin: 10px 0; +} + +.mobile-grid-controls--clue-section-header { + font-weight: bold; + margin-bottom: 5px; +} + +.mobile-grid-controls--clue-item { + padding: 8px 5px; + border-bottom: 1px solid #eee; + display: flex; +} + +.mobile-grid-controls--clue-item.active { + background-color: var(--main-blue-3); +} + +.mobile-grid-controls--clue-item-number { + font-weight: bold; + margin-right: 10px; + min-width: 30px; +} + +/* Grid wrapper for scrolling above keyboard */ +.grid-wrapper { + padding-bottom: 200px; /* space for keyboard */ + overflow-y: auto; +} diff --git a/src/components/Player/css/mobileKeyboard.css b/src/components/Player/css/mobileKeyboard.css index a59ccb78..8e17ea95 100644 --- a/src/components/Player/css/mobileKeyboard.css +++ b/src/components/Player/css/mobileKeyboard.css @@ -50,3 +50,50 @@ width: 40px; letter-spacing: 2px; } + +/* Onscreen keyboard styles */ +.onscreen-keyboard { + display: flex; + flex-wrap: wrap; + padding: 5px; + background: #ddd; + border-top: 1px solid #bbb; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + box-sizing: border-box; + z-index: 5; +} + +.key-btn { + flex: 0 0 10%; /* roughly 10 buttons per row */ + margin: 2px; + font-size: 1.1rem; + padding: 10px 0; + border: none; + border-radius: 4px; + background: #fff; +} + +.key-btn:active { + background: #ccc; +} + +.key-btn.wide { + flex: 0 0 20%; /* wider buttons like backspace */ +} + +/* Dark mode support */ +body.dark-mode .onscreen-keyboard { + background: #555; +} + +body.dark-mode .key-btn { + background: #444; + color: #fff; +} + +body.dark-mode .key-btn:active { + background: #666; +} diff --git a/src/components/Toolbar/css/index.css b/src/components/Toolbar/css/index.css index 6441d173..4c87bf15 100644 --- a/src/components/Toolbar/css/index.css +++ b/src/components/Toolbar/css/index.css @@ -112,6 +112,11 @@ code { justify-content: space-between; font-size: 14pt; color: white; + position: fixed; + top: 0; + left: 0; + width: 100%; + z-index: 10; } .toolbar--mobile a:first-child { @@ -139,6 +144,7 @@ code { align-items: center; position: relative; text-align: center; + width: 100%; } .toolbar--mobile .clock { @@ -148,3 +154,27 @@ code { .toolbar--mobile .toolbar--expand:last-child { margin-right: 16px; } + +/* Mobile Menu Styles */ +.toolbar--mobile-menu { + position: fixed; + top: 48px; + left: 0; + width: 100%; + background-color: #f8f8f8; + z-index: 9; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; + padding: 10px 0; +} + +.toolbar--mobile-menu-item { + padding: 12px 16px; + font-size: 16px; + cursor: pointer; +} + +.toolbar--mobile-menu-item:active { + background-color: #eee; +} diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js index 756d95bb..4b1b0666 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -229,18 +229,11 @@ export default class Toolbar extends Component { renderListViewButton() { const {listMode, mobile} = this.props; if (mobile) { - if (listMode) { - return ( - - ); - } return ( ); } diff --git a/src/global.css b/src/global.css new file mode 100644 index 00000000..0bb76710 --- /dev/null +++ b/src/global.css @@ -0,0 +1,38 @@ +/* Global resets for mobile app feel */ +html, body { + height: 100%; + margin: 0; + padding: 0; + overscroll-behavior: none; /* Prevent bounce scroll on mobile */ + -webkit-overflow-scrolling: none; /* Disable momentum scroll outside content */ +} + +#root { + height: 100%; +} + +body { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; + font-family: sans-serif; + font-size: 14px; + line-height: 1.4; +} + +@media (max-width: 600px) { + body { + font-size: 16px; + } +} + +/* Dark mode considerations */ +body.dark-mode .toolbar--mobile { + background: #222; + color: #fff; +} +body.dark-mode .toolbar--mobile-menu { + background: #333; + color: #fff; +} diff --git a/src/index.js b/src/index.js index 690dc8f8..bb3ba55d 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ import GlobalContext from './lib/GlobalContext'; import './style.css'; import './dark.css'; +import './global.css'; const darkModeLocalStorageKey = 'dark_mode_preference'; diff --git a/src/lib/jsUtils.js b/src/lib/jsUtils.js index 3d39b76e..da885408 100644 --- a/src/lib/jsUtils.js +++ b/src/lib/jsUtils.js @@ -48,7 +48,7 @@ export function colorAverage(hex1, hex2, weight) { if (typeof window !== 'undefined') { window.requestIdleCallback = window.requestIdleCallback || - function(cb) { + function (cb) { const start = Date.now(); return setTimeout(() => { cb({ @@ -62,7 +62,7 @@ if (typeof window !== 'undefined') { window.cancelIdleCallback = window.cancelIdleCallback || - function(id) { + function (id) { clearTimeout(id); }; } @@ -119,6 +119,10 @@ function isAncestor(a, b) { } function isMobile() { + if (typeof window !== 'undefined' && window.innerWidth < 600) { + return true; + } + if (navigator.userAgent.match(/Tablet|iPad/i)) { // do tablet stuff return true;