├── .env ├── .gitignore ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── TODO.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── assets └── social-icons.png ├── components ├── app │ ├── app.js │ └── app.test.js ├── buttons │ ├── fullscreen-button.js │ ├── hint-button.js │ ├── menu-button.css │ ├── menu-button.js │ └── settings-button.js ├── help │ ├── help.css │ └── help.js ├── modal │ ├── modal-about.js │ ├── modal-check-result.js │ ├── modal-confirm-clear-color-highlights.js │ ├── modal-confirm-restart.js │ ├── modal-container.js │ ├── modal-features.js │ ├── modal-hint.js │ ├── modal-icon.js │ ├── modal-invalid-initial-digits.js │ ├── modal-paste.js │ ├── modal-paused.js │ ├── modal-qr-code.js │ ├── modal-resume-or-restart.js │ ├── modal-saved-puzzles.js │ ├── modal-settings.js │ ├── modal-share.js │ ├── modal-solver.js │ ├── modal-welcome.js │ └── modal.css ├── saved-puzzle │ ├── saved-puzzle-grid.js │ └── saved-puzzle-metadata.js ├── solved-puzzle-options │ ├── solved-puzzle-options.css │ └── solved-puzzle-options.js ├── spinner │ ├── spinner.css │ └── spinner.js ├── status-bar │ ├── status-bar.css │ └── status-bar.js ├── sudoku-grid │ ├── grid-dimensions.js │ ├── grid-dimensions.test.js │ ├── grid-lines.js │ ├── sudoku-cell-background.js │ ├── sudoku-cell-cover.js │ ├── sudoku-cell-digit.js │ ├── sudoku-cell-paused.js │ ├── sudoku-cell-pencil-marks.js │ ├── sudoku-cell-region-outline.js │ ├── sudoku-grid.css │ ├── sudoku-grid.js │ ├── sudoku-hint-grid.js │ └── sudoku-mini-grid.js ├── svg-sprites │ ├── button-icon.js │ ├── svg-sprites.css │ └── svg-sprites.js ├── timer-with-pause │ ├── timer-with-pause.css │ └── timer-with-pause.js └── virtual-keyboard │ ├── virtual-keyboard.css │ ├── virtual-keyboard.js │ ├── vkbd-button-icons.js │ ├── vkbd-keyboard-layouts.js │ └── vkbd-mode-panel.js ├── index.css ├── index.js ├── lib ├── modal-types.js ├── not-mutable-list.test.js ├── not-mutable-map.test.js ├── not-mutable-range.test.js ├── not-mutable-set.test.js ├── not-mutable.js ├── region-outlines.js ├── region-outlines.test.js ├── string-utils.js ├── string-utils.test.js ├── sudoku-cell-sets.js ├── sudoku-explainer.js ├── sudoku-explainer.test.js ├── sudoku-hinter.js ├── sudoku-hinter.test.js ├── sudoku-model-dialogs.test.js ├── sudoku-model-solver.test.js ├── sudoku-model.js ├── sudoku-model.test.js └── use-window-size.js ├── serviceWorker.js ├── setupTests.js └── svg └── logo-source.svg /.env: -------------------------------------------------------------------------------- 1 | BROWSER=none 2 | PORT=3000 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | /misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | ## Special thanks to all the people who have helped this project so far: 4 | 5 | * [Barak Cohen](https://github.com/baaraak/) 6 | * [Ben Haines](https://github.com/bhainesva/) 7 | * [Grant McLean](https://github.com/grantm/) 8 | * [Jordan Schroder](https://github.com/stasjs/) 9 | * [Robert Lyon](https://github.com/robertlyon777/) 10 | 11 | ## I would like to join this list. How can I help the project? 12 | 13 | There are a number of suggestions for enhancements on the 14 | [project issues page](https://github.com/grantm/sudoku-web-app/issues). 15 | 16 | Before embarking on a significant development effort, please raise a new issue 17 | or comment on an existing one, to discuss your plans. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project implements the Sudoku web app used on [SudokuExchange.com](https://sudokuexchange.com). 2 | 3 | Features include: 4 | 5 | * Enter a puzzle into a blank grid (e.g: transcribe a printed puzzle) 6 | * Check that the puzzle has a unique solution (in case you made a typo) 7 | * Share a puzzle as a link [like this](https://sudokuexchange.com/play/?s=000001230123008040804007650765000000000000000000000123012300804080400765076500000) 8 | * Two types of pencilmarks (for Snyder notation and doubles/triples or simple candidate lists) 9 | * Cell colouring 10 | * An optional dark mode theme 11 | * Keyboard shortcuts for desktop browsers (including Ctrl-Z/Y Undo/Redo) 12 | * Touchscreen support for mobile or tablet browsers 13 | * Help option on the menu to access user guide 14 | * Multi-cell selections for entering pencil marks 15 | * Flexible display: scales up to huge screens or down to small screens, adapts 16 | automatically to portrait vs landscape orientation, and supports full screen mode 17 | to remove distractions 18 | * Configurable options so you can turn on the features you find helpful and turn 19 | off the features you find annoying 20 | * Free to use and no ads 21 | * Full source code available 22 | 23 | ## Copyright and License 24 | 25 | This software is copyright (c) 2019 Grant McLean and is 26 | released as free software under the terms of the GNU Affero General Public 27 | License (AGPL) version 3 or later. You may use, copy, modify and share the 28 | software under the terms of that license. 29 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Example of slow solver 2 | https://sudokuexchange.com/play/?s=000000000500006100060040030020050040300007800001000000000100002430020050600008700&d=3 3 | 4 | ## Things to do: 5 | (Not necessarily in priority order) 6 | 7 | * Investigate check reporting "may not have a unique solution" when no solution 8 | is possible 9 | * Menu option to share with pencilmarks 10 | * Investigate honouring back button to close modal/menu (without propagating 11 | state when sharing current URL) 12 | * Tests for UI 13 | * Allow pasting in a digit string in 'enter' mode 14 | * Allow copy to digit string 15 | * Review/playback mode with slider and step buttons 16 | * Allow contributed themes 17 | * Add native sharing implementation (https://css-tricks.com/on-the-web-share-api/) 18 | 19 | ## Things done 20 | * Switch from deprecated document.fullscreen to document.fullscreenElement 21 | * Track progress in localStorage to allow resume after reload or back/fwd 22 | * Protect against accidental reload or back button with "are you sure you want 23 | to leave this page?" 24 | * Tests for model 25 | * Render cell layout and text using SVG+CSS 26 | * Mouse input for selecting cells 27 | * Keyboard input for entering/clearing digits 28 | * Keyboard input for selecting cells 29 | * Inner & outer pencil marks 30 | * Enter key to trigger a check & highlight errors 31 | * Undo/redo support 32 | * Set initial digits from querystring 33 | * Input mode for setting initial digits 34 | * Timer 35 | * Highlight matching digits & pencil marks 36 | * On-screen buttons for mobile/touch input 37 | * Adapt screen layout for portrait vs landscape 38 | * Implement restart 39 | * Colour completed digits on virtual keyboard 40 | * Cell colouring 41 | * Pause timer 42 | * Menu option to clear all pencilmarks 43 | * Implement touch event handling 44 | * Allow multiple-cell selection on mobile/touch 45 | * Auto clean pencilmarks 46 | * Double-tap digit to switch to digit mode 47 | * Button to go fullscreen 48 | * Dark mode theme 49 | * Fun visual effect when puzzle is solved 50 | * Block starting if grid has errors 51 | * Validation of digit string in URL (81 digits + no conflicts) 52 | * Settings dialog with selections persisted to LocalStorage 53 | * Help text 54 | * Function to check for unique solutions (new mode) 55 | * Sharing options via social media 56 | * Allow user to share difficulty level and/or solve time 57 | * Add modal to allow selecting from recently shared puzzles 58 | * Option to share via QRcode 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sudoku-web-app", 3 | "version": "1.0.32", 4 | "description": "A web application for solving & sharing Sudoku puzzles. Created for SudokuExchange.com", 5 | "author": { 6 | "name": "Grant McLean", 7 | "email": "grant@mclean.net.nz", 8 | "url": "https://grantm.github.io/" 9 | }, 10 | "license": "AGPLV3", 11 | "repository": "github:grantm/sudoku-web-app", 12 | "bugs": "https://github.com/grantm/sudoku-web-app/issues", 13 | "keywords": [ 14 | "sudoku" 15 | ], 16 | "private": true, 17 | "dependencies": { 18 | "@testing-library/jest-dom": "5.16.5", 19 | "@testing-library/react": "13.4.0", 20 | "@testing-library/user-event": "14.4.3", 21 | "immutable": "4.1.0", 22 | "qrcode.react": "3.1.0", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "react-scripts": "5.0.1", 26 | "save-svg-as-png": "1.4.17" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "lint": "eslint --ext .js src" 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "homepage": ".", 51 | "proxy": "http://sudokuexchange.localhost:1080" 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantm/sudoku-web-app/1f41fed3ecf4ff0583e61e811967f724b603f4d9/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | SudokuExchange.com 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantm/sudoku-web-app/1f41fed3ecf4ff0583e61e811967f724b603f4d9/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantm/sudoku-web-app/1f41fed3ecf4ff0583e61e811967f724b603f4d9/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Sudoku", 3 | "name": "SudokuExchange.com", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/assets/social-icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantm/sudoku-web-app/1f41fed3ecf4ff0583e61e811967f724b603f4d9/src/assets/social-icons.png -------------------------------------------------------------------------------- /src/components/app/app.test.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import App from './app'; 3 | 4 | test('renders learn react link', () => { 5 | expect(true).toBe(true); 6 | // const { getByText } = render(); 7 | // const linkElement = getByText(/learn react/i); 8 | // expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/buttons/fullscreen-button.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | import ButtonIcon from '../svg-sprites/button-icon'; 4 | 5 | let fsApi = null; 6 | 7 | function FullscreenButton () { 8 | fsApi ||= determineFullscreenApi(); 9 | const [fsEnabled, setFsEnabled] = useState(fsApi && !!document[fsApi.ELEMENT_PROP_NAME]); 10 | const title = fsEnabled ? 'Exit full screen' : 'Full screen'; 11 | const clickHandler = (e) => { 12 | e.currentTarget.blur(); 13 | return !!document[fsApi.ELEMENT_PROP_NAME] 14 | ? document[fsApi.EXIT_METHOD_NAME]() 15 | : document.body[fsApi.ENTER_METHOD_NAME](); 16 | }; 17 | useEffect( 18 | () => { 19 | const resizeHandler = (e) => { setFsEnabled(!!document[fsApi.ELEMENT_PROP_NAME]); }; 20 | document.body.addEventListener('fullscreenchange', resizeHandler); 21 | return () => { 22 | document.body.removeEventListener('fullscreenchange', resizeHandler); 23 | } 24 | }, 25 | [setFsEnabled] 26 | ); 27 | return fsApi 28 | ? ( 29 | 32 | ) 33 | : null; 34 | } 35 | 36 | function determineFullscreenApi() { 37 | if (document.body.requestFullscreen) { 38 | return { 39 | ELEMENT_PROP_NAME: "fullscreenElement", 40 | ENTER_METHOD_NAME: "requestFullscreen", 41 | EXIT_METHOD_NAME: "exitFullscreen", 42 | } 43 | } 44 | else if (document.body.webkitRequestFullscreen) { 45 | return { 46 | ELEMENT_PROP_NAME: "webkitFullscreenElement", 47 | ENTER_METHOD_NAME: "webkitRequestFullscreen", 48 | EXIT_METHOD_NAME: "webkitExitFullscreen", 49 | } 50 | } 51 | else { 52 | return null; 53 | } 54 | } 55 | 56 | export default FullscreenButton; 57 | -------------------------------------------------------------------------------- /src/components/buttons/hint-button.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import ButtonIcon from '../svg-sprites/button-icon'; 4 | 5 | export default function HintButton ({menuHandler}) { 6 | const clickHandler = useCallback( 7 | e => { 8 | e.preventDefault(); 9 | const menuAction = 'show-hint-modal'; 10 | menuHandler(menuAction); 11 | }, 12 | [menuHandler] 13 | ); 14 | 15 | return ( 16 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/buttons/menu-button.css: -------------------------------------------------------------------------------- 1 | 2 | .menu .overlay { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | bottom: 0; 7 | right: 0; 8 | background-color: transparent; 9 | } 10 | 11 | .menu.hidden ul { 12 | display: none; 13 | } 14 | 15 | .menu ul { 16 | position: absolute; 17 | right: 10px; 18 | top: 1.8em; 19 | width: 11em; 20 | max-width: 92vw; 21 | max-height: 90vh; 22 | overflow-y: auto; 23 | padding: 2px; 24 | margin: 0; 25 | border: 1px solid var(--menu-border-color); 26 | background-color: var(--menu-bg-color); 27 | box-shadow: var(--menu-box-shadow); 28 | } 29 | 30 | .menu li { 31 | list-style: none; 32 | padding: 0; 33 | font-size: 1.2rem; 34 | } 35 | 36 | .menu a, 37 | .menu a:visited { 38 | display: block; 39 | padding: 0.4em 0.6em; 40 | text-decoration: none; 41 | color: var(--menu-text-color); 42 | } 43 | 44 | .menu a:hover, 45 | .menu a:focus { 46 | color: var(--menu-hover-text-color); 47 | background-color: var(--menu-hover-bg-color); 48 | border: none; 49 | } 50 | 51 | .menu .disabled-link a { 52 | color: var(--menu-disabled-text-color); 53 | font-style: italic; 54 | cursor: not-allowed; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/buttons/menu-button.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | import ButtonIcon from '../svg-sprites/button-icon'; 4 | 5 | import './menu-button.css'; 6 | 7 | 8 | function MenuButton ({initialDigits, showPencilmarks, menuHandler}) { 9 | const [hidden, setHidden] = useState(true); 10 | 11 | const classes = ['menu']; 12 | if (hidden) { 13 | classes.push('hidden') 14 | } 15 | 16 | const toggleHandler = useCallback( 17 | () => setHidden(h => !h), 18 | [] 19 | ); 20 | 21 | const clickHandler = useCallback( 22 | e => { 23 | const parent = e.target.parentElement; 24 | if (parent.classList && parent.classList.contains('disabled-link')) { 25 | e.preventDefault(); 26 | return; 27 | } 28 | const menuAction = e.target.dataset.menuAction; 29 | if (menuAction) { 30 | e.preventDefault(); 31 | menuHandler(menuAction); 32 | } 33 | setHidden(true); 34 | }, 35 | [menuHandler] 36 | ); 37 | 38 | const showHidePencilmarks = showPencilmarks ? 'Hide' : 'Show'; 39 | 40 | const overlay = hidden 41 | ? null 42 | :
setHidden(true)} /> 43 | 44 | const shareLinkClass = initialDigits ? '' : 'disabled-link'; 45 | 46 | return ( 47 |
48 | { overlay } 49 | 52 | 79 |
80 | ) 81 | } 82 | 83 | export default MenuButton; 84 | -------------------------------------------------------------------------------- /src/components/buttons/settings-button.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import ButtonIcon from '../svg-sprites/button-icon'; 4 | 5 | function SettingsButton ({menuHandler}) { 6 | const clickHandler = useCallback( 7 | e => { 8 | e.preventDefault(); 9 | const menuAction = 'show-settings-modal'; 10 | menuHandler(menuAction); 11 | }, 12 | [menuHandler] 13 | ); 14 | 15 | return ( 16 | 19 | ) 20 | } 21 | 22 | export default SettingsButton; 23 | -------------------------------------------------------------------------------- /src/components/help/help.css: -------------------------------------------------------------------------------- 1 | 2 | .help-page { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | bottom: 0; 7 | right: 0; 8 | background-color: var(--background-color); 9 | padding: 2em; 10 | overflow-y: auto; 11 | } 12 | 13 | .help-page .close-button { 14 | position: fixed; 15 | right: 1em; 16 | top: 1em; 17 | font-size: 1.8em; 18 | font-weight: bold; 19 | border: none; 20 | width: 1.3em; 21 | height: 1.3em; 22 | color: var(--status-bar-button-text-color); 23 | background-color: var(--status-bar-button-hover-color); 24 | border-radius: 0.1em; 25 | } 26 | 27 | .help-page .close-button:hover { 28 | background-color: var(--status-bar-button-hover-color); 29 | } 30 | 31 | .help-page .content { 32 | max-width: 800px; 33 | margin: 0 auto; 34 | line-height: 1.4; 35 | padding-bottom: 5em; 36 | } 37 | 38 | .help-page h1 { 39 | font-size: 2rem; 40 | } 41 | 42 | .help-page ul { 43 | padding-left: 1.4em; 44 | } 45 | 46 | .help-page li { 47 | margin-bottom: 0.3em; 48 | } 49 | 50 | .help-page dt { 51 | font-weight: 600; 52 | } 53 | 54 | .help-page dd { 55 | margin: 0 0 0.8em 1.6em; 56 | } 57 | 58 | @media screen and (max-width: 500px) { 59 | 60 | .help-page { 61 | padding: 1.2em; 62 | } 63 | 64 | .help-page .close-button { 65 | right: 0.4em; 66 | top: 0.4em; 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/components/modal/modal-about.js: -------------------------------------------------------------------------------- 1 | import projectPackageJson from '../../../package.json'; 2 | 3 | const appVersion = projectPackageJson.version || "unknown"; 4 | 5 | 6 | export default function ModalAbout({modalHandler}) { 7 | const closeHandler = () => modalHandler('cancel'); 8 | const thisYear = (new Date()).getFullYear(); 9 | return ( 10 |
11 |

About this app

12 |

Application version: {appVersion}

13 |

This Sudoku web application was created for SudokuExchange.com by Grant McLean.

15 |

It is free software{' '} 16 | which you can use, copy, modify and share under the terms of the 17 | GNU Affero General Public License version 3 (AGPLV3). The source code is available at:
19 | https://github.com/grantm/sudoku-web-app.

20 |

Copyright © 2020{thisYear > 2020 ? `-${thisYear}` : ''} Grant McLean

21 |
22 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/modal/modal-check-result.js: -------------------------------------------------------------------------------- 1 | import ModalIcon from './modal-icon'; 2 | 3 | export default function ModalCheckResult({modalState, modalHandler}) { 4 | const errorMessage = modalState.errorMessage; 5 | const cancelHandler = () => modalHandler('cancel'); 6 | const icon = modalState.icon 7 | ? 8 | : null; 9 | return ( 10 |
11 |
12 | {icon} 13 |
{errorMessage}
14 |
15 |
16 | 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/modal/modal-confirm-clear-color-highlights.js: -------------------------------------------------------------------------------- 1 | export default function ModalConfirmClearColorHighlights({modalHandler}) { 2 | const cancelHandler = () => modalHandler('cancel'); 3 | const restartHandler = () => modalHandler('clear-color-highlights-confirmed'); 4 | return ( 5 |
6 |

Clear all colour highlighting?

7 |

Are you sure you wish to remove the colour highlighting from all cells?

8 |
9 | 10 | 11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/modal/modal-confirm-restart.js: -------------------------------------------------------------------------------- 1 | export default function ModalConfirmRestart({modalHandler, solved}) { 2 | const cancelHandler = () => modalHandler('cancel'); 3 | const restartHandler = () => modalHandler('restart-confirmed'); 4 | return ( 5 |
6 |

Restart the puzzle?

7 | { 8 | solved 9 | ? ( 10 |

Are you sure you wish to restart?

11 | ) 12 | : ( 13 |

Are you sure you wish to discard all the numbers and 14 | pencil-marks you've entered?

15 | ) 16 | } 17 |
18 | 19 | 20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/modal/modal-container.js: -------------------------------------------------------------------------------- 1 | import { 2 | MODAL_TYPE_WELCOME, 3 | MODAL_TYPE_SAVED_PUZZLES, 4 | MODAL_TYPE_RESUME_OR_RESTART, 5 | MODAL_TYPE_INVALID_INITIAL_DIGITS, 6 | MODAL_TYPE_PASTE, 7 | MODAL_TYPE_SHARE, 8 | MODAL_TYPE_SETTINGS, 9 | MODAL_TYPE_CHECK_RESULT, 10 | MODAL_TYPE_PAUSED, 11 | MODAL_TYPE_CONFIRM_RESTART, 12 | MODAL_TYPE_CONFIRM_CLEAR_COLOR_HIGHLIGHTS, 13 | MODAL_TYPE_SOLVER, 14 | MODAL_TYPE_HELP, 15 | MODAL_TYPE_HINT, 16 | MODAL_TYPE_ABOUT, 17 | MODAL_TYPE_QR_CODE, 18 | MODAL_TYPE_FEATURES, 19 | } from '../../lib/modal-types'; 20 | 21 | 22 | import ModalWelcome from './modal-welcome'; 23 | import ModalSavedPuzzles from './modal-saved-puzzles'; 24 | import ModalResumeRestart from './modal-resume-or-restart'; 25 | import ModalInvalidInitialDigits from './modal-invalid-initial-digits'; 26 | import ModalAbout from './modal-about'; 27 | import ModalConfirmRestart from './modal-confirm-restart'; 28 | import ModalConfirmClearColorHighlights from './modal-confirm-clear-color-highlights' 29 | import ModalCheckResult from './modal-check-result'; 30 | import ModalPaused from './modal-paused'; 31 | import ModalPaste from './modal-paste'; 32 | import ModalShare from './modal-share'; 33 | import ModalSolver from './modal-solver'; 34 | import ModalSettings from './modal-settings'; 35 | import ModalQRCode from './modal-qr-code'; 36 | import ModalHint from './modal-hint'; 37 | import ModalFeatures from './modal-features'; 38 | import HelpPage from '../help/help'; 39 | 40 | import "./modal.css"; 41 | 42 | const stopPropagation = (e) => e.stopPropagation(); 43 | 44 | function ModalBackdrop() { 45 | return ( 46 |
47 | ); 48 | } 49 | 50 | export default function ModalContainer({modalState, modalHandler, menuHandler}) { 51 | let content = null; 52 | if (!modalState) { 53 | return null; 54 | } 55 | const containerClickHandler = (e) => { 56 | if (e.target === e.currentTarget) { 57 | if (modalState.escapeAction) { 58 | modalHandler(modalState.escapeAction); 59 | } 60 | } 61 | } 62 | if (modalState.modalType === MODAL_TYPE_WELCOME) { 63 | content = ; 64 | } 65 | else if (modalState.modalType === MODAL_TYPE_SAVED_PUZZLES) { 66 | content = ; 67 | } 68 | else if (modalState.modalType === MODAL_TYPE_RESUME_OR_RESTART) { 69 | content = ; 70 | } 71 | else if (modalState.modalType === MODAL_TYPE_INVALID_INITIAL_DIGITS) { 72 | content = ; 73 | } 74 | else if (modalState.modalType === MODAL_TYPE_PASTE) { 75 | content = ; 76 | } 77 | else if (modalState.modalType === MODAL_TYPE_ABOUT) { 78 | content = ; 79 | } 80 | else if (modalState.modalType === MODAL_TYPE_SHARE) { 81 | content = ; 82 | } 83 | else if (modalState.modalType === MODAL_TYPE_SETTINGS) { 84 | content = ; 85 | } 86 | else if (modalState.modalType === MODAL_TYPE_CONFIRM_RESTART) { 87 | content = ; 88 | } 89 | else if (modalState.modalType === MODAL_TYPE_CONFIRM_CLEAR_COLOR_HIGHLIGHTS) { 90 | content = ; 91 | } 92 | else if (modalState.modalType === MODAL_TYPE_CHECK_RESULT) { 93 | content = ; 94 | } 95 | else if (modalState.modalType === MODAL_TYPE_PAUSED) { 96 | content = ; 97 | } 98 | else if (modalState.modalType === MODAL_TYPE_SOLVER) { 99 | content = ; 100 | } 101 | else if (modalState.modalType === MODAL_TYPE_HINT) { 102 | content = ; 103 | } 104 | else if (modalState.modalType === MODAL_TYPE_HELP) { 105 | content = ; 106 | } 107 | else if (modalState.modalType === MODAL_TYPE_QR_CODE) { 108 | content = ; 109 | } 110 | else if (modalState.modalType === MODAL_TYPE_FEATURES) { 111 | content = ; 112 | } 113 | else { 114 | console.log(': Unhandled modalState:', modalState); 115 | } 116 | if (content) { 117 | return <> 118 | 119 |
120 | {content} 121 |
122 | ; 123 | }; 124 | return null; 125 | } 126 | -------------------------------------------------------------------------------- /src/components/modal/modal-features.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | 4 | const issueBaseUrl = "https://github.com/grantm/sudoku-web-app/issues"; 5 | 6 | function FeatureCheckBox ({name, allFeatureFlags, setFeatureFlag}) { 7 | const currValue = !!allFeatureFlags[name]; 8 | return ; 18 | } 19 | 20 | 21 | function featureInputs(availableFeatures, allFeatureFlags, setFeatureFlag) { 22 | const featureRows = availableFeatures.map(f => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | #{f.issueNumber} 32 | 33 | ); 34 | }); 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {featureRows} 45 | 46 |
FeatureIssue
47 | ); 48 | } 49 | 50 | 51 | export default function ModalFeatures({modalState, modalHandler}) { 52 | const {availableFeatures, enabledFeatures} = modalState; 53 | const [allFeatureFlags, setAllFeatureFlags] = useState(enabledFeatures || {}); 54 | const setFeatureFlag = useCallback( 55 | (name, newValue) => { 56 | console.log(`setting ${name} to ${newValue}`); 57 | const newFeatureFlags = { ...allFeatureFlags, [name]: newValue }; 58 | setAllFeatureFlags(newFeatureFlags); 59 | }, 60 | [allFeatureFlags] 61 | ); 62 | const cancelHandler = () => modalHandler('goto-main-entry'); 63 | const saveHandler = () => modalHandler({ 64 | action: 'save-feature-flags', 65 | newFeatureFlags: allFeatureFlags, 66 | }); 67 | const featureList = (availableFeatures && availableFeatures.length > 0) 68 | ? featureInputs(availableFeatures, allFeatureFlags, setFeatureFlag) 69 | :

Sorry there are no features for testing at this time.

; 70 | return ( 71 |
72 |

Feature flags

73 |

This is where you can turn on and off features that are available 74 | for beta testing. If you discover a bug, please help by leaving a comment 75 | or reaction on the linked issue #.

76 | {featureList} 77 |
78 | 79 | 80 |
81 |
82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/components/modal/modal-hint.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import Spinner from '../spinner/spinner'; 4 | import SudokuHintGrid from '../sudoku-grid/sudoku-hint-grid.js'; 5 | 6 | import { classList } from '../../lib/string-utils'; 7 | 8 | const solverURL = "https://github.com/SudokuMonster/SukakuExplainer/"; 9 | 10 | 11 | function DifficultyIndicator({hint}) { 12 | if (!hint.stepRating) { 13 | return null; 14 | } 15 | return ( 16 |
17 |
Difficulty
18 |
{hint.stepRating}
19 |
{hint.puzzleRating}
20 |
Step
21 |
Puzzle
22 |
23 | ); 24 | } 25 | 26 | function textWithRCHotSpots(text, hotSpotHandler) { 27 | return text.match(/(\b[rR][1-9][cC][1-9]\b|.+?(?=$|\b[rR][1-9][cC][1-9]\b))/g).map((s, i) => { 28 | const m = s.match(/^[rR]([1-9])[cC]([1-9])$/); 29 | if (m) { 30 | const index = (parseInt(m[1], 10) - 1) * 9 + parseInt(m[2], 10) - 1; 31 | return ( 32 | {s.toUpperCase()} 41 | ); 42 | } 43 | else { 44 | return s; 45 | } 46 | }) 47 | } 48 | 49 | function HintBody({hint, hotSpotHandler, hotSpotIndex}) { 50 | const digits = hint.digits; 51 | const candidates = hint.candidates; 52 | 53 | const hintParas = hint.html.split(/<\/?p>/).filter(s => s && s.length > 0).map((s, i) => { 54 | return

{textWithRCHotSpots(s, hotSpotHandler)}

; 55 | }) 56 | 57 | return ( 58 |
59 | 68 |
69 | 70 |
71 | {hintParas} 72 |
73 |
74 |
75 | ); 76 | } 77 | 78 | function modalHintContent ({loading, loadingFailed, errorMessage, hint, hotSpotHandler, hotSpotIndex}) { 79 | if (loading) { 80 | return { 81 | title: "Loading hints", 82 | modalContent: , 83 | primaryButtonText: "Cancel", 84 | } 85 | } 86 | else if (loadingFailed) { 87 | console.log("Failed to load hint: ", errorMessage); 88 | const description = errorMessage === "Error: 400 Bad Request" 89 | ? ( 90 |

The server was unable to provide hints for this puzzle.

91 | ) 92 | : <> 93 |

An error occurred while requesting hints from the server.

94 |

You may wish to try again later.

95 | ; 96 | return { 97 | title: "Failed to load hints", 98 | modalContent: description, 99 | primaryButtonText: "Cancel", 100 | } 101 | } 102 | else if (hint) { 103 | return { 104 | title: textWithRCHotSpots(hint.title.replace(/,/g, ',\u200B'), hotSpotHandler), 105 | modalContent: , 106 | primaryButtonText: "OK", 107 | } 108 | } 109 | } 110 | 111 | function HintButtons({loading, hint, modalHandler, menuHandler, children}) { 112 | const closeHandler = () => modalHandler('cancel'); 113 | 114 | const candidatesHandler = () => { 115 | menuHandler("calculate-candidates"); 116 | modalHandler('cancel'); 117 | }; 118 | const candidatesButton = (hint && hint.needCandidates) 119 | ? 120 | : null; 121 | 122 | const applyHintHandler = () => { 123 | modalHandler({action: 'apply-hint', hint}); 124 | } 125 | const applyHintButton = (!loading && hint && !candidatesButton) 126 | ? 127 | : null; 128 | 129 | return ( 130 |
131 | {candidatesButton} 132 | {applyHintButton} 133 | 134 |
135 | ); 136 | } 137 | 138 | export default function ModalHint({modalState, modalHandler, menuHandler}) { 139 | const [hotSpot, setHotSpot] = useState(null); 140 | const hotSpotHandler = (e) => { 141 | e.stopPropagation(); 142 | const eventType = e.type; 143 | const index = parseInt(e.target.dataset.cellIndex, 10); 144 | if (index === undefined) { 145 | return setHotSpot(null); 146 | } 147 | if (eventType === 'mouseleave') { 148 | if (hotSpot && hotSpot.type === 'mouseenter') { 149 | return setHotSpot(null); 150 | } 151 | } 152 | else if (eventType === 'touchStart' && hotSpot && hotSpot.type === 'touchstart') { 153 | return setHotSpot(null); 154 | } 155 | else { 156 | return setHotSpot({ 157 | type: eventType, 158 | index: index, 159 | }) 160 | } 161 | } 162 | const modalClasses = classList( 163 | "modal hint", 164 | modalState.loading && "loading", 165 | modalState.loadingFailed && "loading-failed", 166 | ); 167 | 168 | const hotSpotIndex = hotSpot && hotSpot.index; 169 | const {title, modalContent, primaryButtonText} = modalHintContent({...modalState, hotSpotHandler, hotSpotIndex}); 170 | 171 | return ( 172 |
173 |
174 |
175 |

{title}

176 | {modalContent} 177 | {primaryButtonText} 183 |
184 |
185 | Hints by: Sukaku Explainer 186 |
187 |
188 |
189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /src/components/modal/modal-icon.js: -------------------------------------------------------------------------------- 1 | export default function ModalIcon ({icon}) { 2 | return ( 3 | 4 | 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/modal/modal-invalid-initial-digits.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { expandPuzzleDigits } from '../../lib/string-utils'; 4 | 5 | 6 | function ModalInsufficientInitialDigits({modalHandler}) { 7 | const [linkText, setLinkText] = useState(''); 8 | 9 | const inputHandler = (e) => { 10 | let text = e.target.value; 11 | let match = text.replace(/\s+/gs, '').match(/s=(\d{81}|[0-9a-zA-Z]{13,81})/); 12 | text = match ? match[1] : text; 13 | if(text.match(/^[0-9a-zA-Z]+$/)) { 14 | text = expandPuzzleDigits(text); 15 | } 16 | setLinkText(text); 17 | }; 18 | const haveValidDigits = linkText.match(/^\d{81}$/); 19 | const submitClass = haveValidDigits ? 'primary' : null; 20 | const cancelHandler = () => modalHandler('cancel'); 21 | const retryHandler = () => { 22 | if (haveValidDigits) { 23 | modalHandler({ 24 | action: 'retry-initial-digits', 25 | digits: linkText, 26 | }); 27 | } 28 | } 29 | return ( 30 |
31 |

Missing digits

32 |

You clicked a link that was missing some of the expected 81 digits. 33 | If the link was split over multiple lines in an email, you can try 34 | pasting all the lines below.
35 | Or press "Cancel" to enter digits directly into the grid.

36 |