Merge pull request 'feature/algorithm' (#5) from feature/algorithm into main
Reviewed-on: #5
This commit is contained in:
		
						commit
						0abde5611b
					
				
					 43 changed files with 3450 additions and 18685 deletions
				
			
		
							
								
								
									
										31
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -1,20 +1,41 @@ | |||
| /.idea | ||||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||||
| 
 | ||||
| # dependencies | ||||
| /node_modules | ||||
| /.pnp | ||||
| .pnp.* | ||||
| .yarn/* | ||||
| !.yarn/patches | ||||
| !.yarn/plugins | ||||
| !.yarn/releases | ||||
| !.yarn/versions | ||||
| 
 | ||||
| # testing | ||||
| /coverage | ||||
| 
 | ||||
| # next.js | ||||
| /.next/ | ||||
| /out/ | ||||
| 
 | ||||
| # production | ||||
| /build | ||||
| 
 | ||||
| # misc | ||||
| .DS_Store | ||||
| .env.local | ||||
| .env.development.local | ||||
| .env.test.local | ||||
| .env.production.local | ||||
| *.pem | ||||
| 
 | ||||
| # debug | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| .pnpm-debug.log* | ||||
| 
 | ||||
| # env files (can opt-in for committing if needed) | ||||
| .env* | ||||
| 
 | ||||
| # vercel | ||||
| .vercel | ||||
| 
 | ||||
| # typescript | ||||
| *.tsbuildinfo | ||||
| ../next-env.d.ts | ||||
|  |  | |||
							
								
								
									
										16
									
								
								eslint.config.mjs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								eslint.config.mjs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { dirname } from "path"; | ||||
| import { fileURLToPath } from "url"; | ||||
| import { FlatCompat } from "@eslint/eslintrc"; | ||||
| 
 | ||||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = dirname(__filename); | ||||
| 
 | ||||
| const compat = new FlatCompat({ | ||||
|   baseDirectory: __dirname, | ||||
| }); | ||||
| 
 | ||||
| const eslintConfig = [ | ||||
|   ...compat.extends("next/core-web-vitals", "next/typescript"), | ||||
| ]; | ||||
| 
 | ||||
| export default eslintConfig; | ||||
							
								
								
									
										5
									
								
								next-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								next-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| /// <reference types="next" />
 | ||||
| /// <reference types="next/image-types/global" />
 | ||||
| 
 | ||||
| // NOTE: This file should not be edited
 | ||||
| // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
 | ||||
							
								
								
									
										9
									
								
								next.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								next.config.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | |||
| import type {NextConfig} from "next"; | ||||
| 
 | ||||
| const nextConfig: NextConfig = { | ||||
|     /* config options here */ | ||||
|     output: "export", | ||||
|     assetPrefix: "./" | ||||
| }; | ||||
| 
 | ||||
| export default nextConfig; | ||||
							
								
								
									
										19044
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										19044
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										50
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										50
									
								
								package.json
									
										
									
									
									
								
							|  | @ -1,37 +1,25 @@ | |||
| { | ||||
|   "dependencies": { | ||||
|     "@types/jest": "^29.5.0", | ||||
|     "@types/node": "^18.15.11", | ||||
|     "@types/react": "^18.0.35", | ||||
|     "@types/react-dom": "^18.0.11", | ||||
|     "react": "^18.0.0", | ||||
|     "react-dom": "^18.0.0", | ||||
|     "typescript": "^5.0.4" | ||||
|   }, | ||||
|   "main": "/index.js", | ||||
|   "homepage": ".", | ||||
|   "devDependencies": { | ||||
|     "react-scripts": "^5.0.1" | ||||
|   }, | ||||
|   "name": "a-maze-r", | ||||
|   "description": "The A-Maze-Ing Generator!", | ||||
|   "version": "0.0.0", | ||||
|   "version": "0.1.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "start": "react-scripts start", | ||||
|     "build": "react-scripts build", | ||||
|     "test": "react-scripts test --env=jsdom", | ||||
|     "eject": "react-scripts eject" | ||||
|     "dev": "next dev", | ||||
|     "build": "next build", | ||||
|     "start": "next start", | ||||
|     "lint": "next lint" | ||||
|   }, | ||||
|   "browserslist": { | ||||
|     "production": [ | ||||
|       ">0.2%", | ||||
|       "not dead", | ||||
|       "not op_mini all" | ||||
|     ], | ||||
|     "development": [ | ||||
|       "last 1 chrome version", | ||||
|       "last 1 firefox version", | ||||
|       "last 1 safari version" | ||||
|     ] | ||||
|   "dependencies": { | ||||
|     "react": "^19.0.0", | ||||
|     "react-dom": "^19.0.0", | ||||
|     "next": "15.1.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "typescript": "^5", | ||||
|     "@types/node": "^20", | ||||
|     "@types/react": "^19", | ||||
|     "@types/react-dom": "^19", | ||||
|     "eslint": "^9", | ||||
|     "eslint-config-next": "15.1.2", | ||||
|     "@eslint/eslintrc": "^3" | ||||
|   } | ||||
| } | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 24 KiB | 
|  | @ -1,38 +0,0 @@ | |||
| <!doctype html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|   <meta charset="utf-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
|   <meta name="theme-color" content="#000000"> | ||||
|   <!-- | ||||
|     manifest.json provides metadata used when your web app is added to the | ||||
|     homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ | ||||
|   --> | ||||
|   <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> | ||||
|   <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> | ||||
|   <!-- | ||||
|     Notice the use of %PUBLIC_URL% in the tags above. | ||||
|     It will be replaced with the URL of the `public` folder during the build. | ||||
|     Only files inside the `public` folder can be referenced from the HTML. | ||||
| 
 | ||||
|     Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | ||||
|     work correctly both with client-side routing and a non-root public URL. | ||||
|     Learn how to configure a non-root public URL by running `npm run build`. | ||||
|   --> | ||||
|   <title>A-Maze-R</title> | ||||
|    | ||||
| </head> | ||||
| <body> | ||||
|   <div id="root"></div> | ||||
|   <!-- | ||||
|     This HTML file is a template. | ||||
|     If you open it directly in the browser, you will see an empty page. | ||||
| 
 | ||||
|     You can add webfonts, meta tags, or analytics to this file. | ||||
|     The build step will place the bundled scripts into the <body> tag. | ||||
| 
 | ||||
|     To begin the development, run `npm start` or `yarn start`. | ||||
|     To create a production bundle, use `npm run build` or `yarn build`. | ||||
|   --> | ||||
| </body> | ||||
| </html> | ||||
|  | @ -1,15 +0,0 @@ | |||
| { | ||||
|   "short_name": "React App", | ||||
|   "name": "Create React App Sample", | ||||
|   "icons": [ | ||||
|     { | ||||
|       "src": "favicon.ico", | ||||
|       "sizes": "192x192", | ||||
|       "type": "image/png" | ||||
|     } | ||||
|   ], | ||||
|   "start_url": "./index.html", | ||||
|   "display": "standalone", | ||||
|   "theme_color": "#000000", | ||||
|   "background_color": "#ffffff" | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/Cell.tsx
									
										
									
									
									
								
							
							
						
						
									
										32
									
								
								src/Cell.tsx
									
										
									
									
									
								
							|  | @ -1,32 +0,0 @@ | |||
| import {MazeCell} from "./model/Maze"; | ||||
| import Coordinates from "./model/Coordinates"; | ||||
| import {actionClickedCell} from "./state/action.ts"; | ||||
| 
 | ||||
| function isMarked(x: number, y: number, marked: Coordinates[]): boolean { | ||||
|     return !!marked.find(e => e.x === x && e.y === y); | ||||
| } | ||||
| 
 | ||||
| export default function Cell({x, y, state, dispatch}) { | ||||
|     const cell: MazeCell = state.maze.grid[y][x]; | ||||
|     let classes = "cell r" + y + " c" + x; | ||||
|     if (cell.top) classes += " top"; | ||||
|     if (cell.right) classes += " right"; | ||||
|     if (cell.bottom) classes += " bottom"; | ||||
|     if (cell.left) classes += " left"; | ||||
|     if (cell.solution && state.showSolution) classes += " solution"; | ||||
|     const marked = isMarked(x, y, state.userPath); | ||||
|     if (marked) classes += " user"; | ||||
|     return ( | ||||
|         <div className={classes} | ||||
|              onMouseEnter={(e) => { | ||||
|                  const leftPressed = e.buttons & 0x1; | ||||
|                  if (leftPressed) { | ||||
|                      dispatch(actionClickedCell(x, y)); | ||||
|                  } | ||||
|              }} | ||||
|              onClick={(e) => { | ||||
|                  dispatch(actionClickedCell(x, y)); | ||||
|              }}> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -1,96 +0,0 @@ | |||
| import {useState} from 'react'; | ||||
| import ValidatingInputNumberField from "./ValidatingInputNumberField.tsx"; | ||||
| import {actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts"; | ||||
| 
 | ||||
| export default function InputForm({state, dispatch}) { | ||||
|     const [width, setWidth] = useState(10); | ||||
|     const [height, setHeight] = useState(10); | ||||
|     const [id, setId] = useState(null as number); | ||||
| 
 | ||||
|     const handleSubmit = (e) => { | ||||
|         e.preventDefault(); | ||||
|         dispatch(actionStartedLoading()); | ||||
|         const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}`; | ||||
|         fetch(url) | ||||
|             .then(response => response.json()) | ||||
|             // .then(result => new Promise(resolve => setTimeout(resolve, 600, result)))
 | ||||
|             .then(result => { | ||||
|                 dispatch(actionLoadedMaze(result)); | ||||
|             }) | ||||
|             .catch(reason => { | ||||
|                 console.error("Failed to fetch maze data.", reason); | ||||
|                 dispatch(actionLoadingFailed(reason)); | ||||
|             }); | ||||
|     }; | ||||
|     const validateWidthHeightInput = value => { | ||||
|         if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be an integer greater than 1.", | ||||
|                 value | ||||
|             }; | ||||
|         } | ||||
|         if (value < 1) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be greater than 1.", | ||||
|                 value | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             valid: true, | ||||
|             value | ||||
|         }; | ||||
|     }; | ||||
|     const validateIdInput = value => { | ||||
|         // FIXME doesn't handle strings with characters correctly (e.g. "asdf" yields an empty value, due to "type=number").
 | ||||
|         if (isNaN(value) || ("" !== value && ((Math.floor(value) !== Number(value))))) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be empty or an integer", | ||||
|                 value | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             valid: true, | ||||
|             value | ||||
|         } | ||||
|     }; | ||||
|     return ( | ||||
|         <form onSubmit={handleSubmit}> | ||||
|             <ValidatingInputNumberField id={"width"} | ||||
|                                         label={"Width"} | ||||
|                                         value={width} | ||||
|                                         constraints={{ | ||||
|                                             min: 2 | ||||
|                                         }} | ||||
|                                         validatorFn={validateWidthHeightInput} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setWidth} | ||||
|             /><br/> | ||||
|             <ValidatingInputNumberField id={"height"} | ||||
|                                         label={"Height"} | ||||
|                                         value={height} | ||||
|                                         constraints={{ | ||||
|                                             min: 2 | ||||
|                                         }} | ||||
|                                         validatorFn={validateWidthHeightInput} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setHeight} | ||||
|             /><br/> | ||||
|             <ValidatingInputNumberField id={"id"} | ||||
|                                         label={"ID (optional)"} | ||||
|                                         value={id} | ||||
|                                         validatorFn={validateIdInput} | ||||
|                                         disabled={state.loading} | ||||
|                                         onChange={setId} | ||||
|             /><br/> | ||||
|             <button type={"submit"} | ||||
|                     disabled={state.loading | ||||
|                         || isNaN(width) | ||||
|                         || isNaN(height) | ||||
|                     }>{state.loading ? "Loading ..." : "Create Maze!"} | ||||
|             </button> | ||||
|         </form> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										22
									
								
								src/Maze.tsx
									
										
									
									
									
								
							
							
						
						
									
										22
									
								
								src/Maze.tsx
									
										
									
									
									
								
							|  | @ -1,22 +0,0 @@ | |||
| import Cell from "./Cell.tsx"; | ||||
| 
 | ||||
| 
 | ||||
| export default function Maze({state, dispatch}) { | ||||
|     if (!state.maze) { | ||||
|         return <div>No valid maze.</div> | ||||
|     } | ||||
| 
 | ||||
|     let maze: JSX.Element[] = []; | ||||
|     for (let y = 0; y < state.maze.height; y++) { | ||||
|         let row: JSX.Element[] = []; | ||||
|         for (let x = 0; x < state.maze.width; x++) { | ||||
|             row.push(<Cell key={`${x}x${y}`} x={x} y={y} state={state} dispatch={dispatch}/>) | ||||
|         } | ||||
|         maze.push(<div key={`r${y}`} className={"row"}>{row}</div>); | ||||
|     } | ||||
|     return ( | ||||
|         <div className={"maze"}> | ||||
|             {maze} | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -1,15 +0,0 @@ | |||
| import {actionClosedMessageBanner} from "./state/action.ts"; | ||||
| 
 | ||||
| export default function MessageBanner({state, dispatch}) { | ||||
|     function handleClose() { | ||||
|         dispatch(actionClosedMessageBanner()); | ||||
|     } | ||||
| 
 | ||||
|     if (!!state.errorMessage) { | ||||
|         return (<div className={"message-banner"}> | ||||
|             {state.errorMessage} | ||||
|             <button onClick={handleClose}>Dismiss message</button> | ||||
|         </div>); | ||||
|     } | ||||
|     return <></>; | ||||
| } | ||||
|  | @ -1,41 +0,0 @@ | |||
| import React, {useState} from 'react'; | ||||
| 
 | ||||
| export default function ValidatingInputNumberField({ | ||||
|                                                        id, | ||||
|                                                        label, | ||||
|                                                        value = 0, | ||||
|                                                        constraints = {}, | ||||
|                                                        validatorFn = (value) => { | ||||
|                                                            return {valid: true, value}; | ||||
|                                                        }, | ||||
|                                                        disabled = false, | ||||
|                                                        onChange = _ => { | ||||
|                                                        } | ||||
|                                                    }) { | ||||
|     const [error, setError] = useState(null); | ||||
| 
 | ||||
|     const handleValueChange = (e) => { | ||||
|         const value = e.target.value; | ||||
|         const validation = validatorFn(value); | ||||
|         if (!validation.valid) { | ||||
|             setError(validation.message); | ||||
|         } else { | ||||
|             setError(null); | ||||
|         } | ||||
|         onChange(validation.value); | ||||
|     }; | ||||
|     return ( | ||||
|         <> | ||||
|             <label htmlFor={id}>{label}: </label> | ||||
|             <input id={id} | ||||
|                    type={"number"} | ||||
|                    onChange={handleValueChange} | ||||
|                    value={value || ""} | ||||
|                    min={constraints.min || null} | ||||
|                    max={constraints.max || null} | ||||
|                    disabled={disabled} | ||||
|             /> | ||||
|             {!!error && <span>{error}</span>} | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										35
									
								
								src/app/cell.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/app/cell.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| .solution { | ||||
|     background-color: var(--color-maze-cell-solution); | ||||
| } | ||||
| 
 | ||||
| .solution:hover { | ||||
|     background-color: var(--color-maze-cell-solution-highlight); | ||||
| } | ||||
| 
 | ||||
| .top { | ||||
|     border-top-color: var(--color-maze-border); | ||||
| } | ||||
| 
 | ||||
| .right { | ||||
|     border-right-color: var(--color-maze-border); | ||||
| } | ||||
| 
 | ||||
| .bottom { | ||||
|     border-bottom-color: var(--color-maze-border); | ||||
| } | ||||
| 
 | ||||
| .left { | ||||
|     border-left-color: var(--color-maze-border); | ||||
| } | ||||
| 
 | ||||
| .user{ | ||||
|     background-color: var(--color-maze-cell-user); | ||||
| } | ||||
| 
 | ||||
| .user:hover { | ||||
|     background-color: var(--color-maze-cell-user-highlight); | ||||
| } | ||||
| 
 | ||||
| .solution.user { | ||||
|     background-color: #c8ff00; | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/app/cell.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/cell.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| .cell { | ||||
|     display: table-cell; | ||||
|     border: 1px solid transparent; | ||||
|     height: 2em; | ||||
|     width: 2em; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| .cell:hover { | ||||
|     background-color: var(--color-background-highlight); | ||||
| } | ||||
							
								
								
									
										42
									
								
								src/app/cell.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/app/cell.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import {MazeCell} from "./model/maze.ts"; | ||||
| import Coordinates from "./model/coordinates.ts"; | ||||
| import {Action, actionClickedCell} from "./state/action.ts"; | ||||
| import styles from "./cell.module.css"; | ||||
| import "./cell.css"; | ||||
| import {State} from "./state/state.ts"; | ||||
| import {ActionDispatch} from "react"; | ||||
| 
 | ||||
| function isMarked(x: number, y: number, marked: Coordinates[]): boolean { | ||||
|     return !!marked.find(e => e.x === x && e.y === y); | ||||
| } | ||||
| 
 | ||||
| export default function Cell({x, y, state, dispatch}: | ||||
|                              { | ||||
|                                  x: number, | ||||
|                                  y: number, | ||||
|                                  state: State, | ||||
|                                  dispatch: ActionDispatch<[Action]> | ||||
|                              }) { | ||||
|     const cell: MazeCell = state.maze!.grid[y][x]; | ||||
|     let classes = " r" + y + " c" + x; | ||||
|     if (cell.top) classes += " top"; | ||||
|     if (cell.right) classes += " right"; | ||||
|     if (cell.bottom) classes += " bottom"; | ||||
|     if (cell.left) classes += " left"; | ||||
|     if (cell.solution && state.showSolution) classes += " solution"; | ||||
|     const marked = isMarked(x, y, state.userPath); | ||||
|     if (marked) classes += " user"; | ||||
|     return ( | ||||
|         <div className={styles.cell + classes} | ||||
|              onMouseEnter={(e) => { | ||||
|                  const leftPressed = e.buttons & 0x1; | ||||
|                  if (leftPressed) { | ||||
|                      dispatch(actionClickedCell(x, y)); | ||||
|                  } | ||||
|              }} | ||||
|              onClick={() => { | ||||
|                  dispatch(actionClickedCell(x, y)); | ||||
|              }}> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								src/app/favicon.ico
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/app/favicon.ico
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 894 B | 
							
								
								
									
										54
									
								
								src/app/globals.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/app/globals.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| :root { | ||||
|     box-sizing: border-box; | ||||
|     --color-background: #e8e6e3; | ||||
|     --color-foreground: #181a1b; | ||||
|     --color-form-error: #ff0000; | ||||
|     --color-border: #5d6164; | ||||
|     --color-background-highlight: #f8f7f4; | ||||
|     --color-foreground-highlight: #292b2c; | ||||
|     --color-border-highlight: #6e7275; | ||||
|     --color-maze-background: #e8e6e3; | ||||
|     --color-maze-border: #333333; | ||||
|     --color-maze-cell-solution: #b1d5b1; | ||||
|     --color-maze-cell-solution-highlight: #b9e8b9; | ||||
|     --color-maze-cell-user: #ffcc00; | ||||
|     --color-maze-cell-user-highlight: #ffdd22; | ||||
| } | ||||
| 
 | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     :root { | ||||
|         --color-background: #181a1b; | ||||
|         --color-foreground: #e8e6e3; | ||||
|         --color-form-error: #ff0000; | ||||
|         --color-border: #5d6164; | ||||
|         --color-background-highlight: #292b2c; | ||||
|         --color-foreground-highlight: #f8f7f4; | ||||
|         --color-border-highlight: #6e7275; | ||||
|         --color-maze-background: #181a1b; | ||||
|         --color-maze-border: #e8e6e3; | ||||
|         --color-maze-cell-solution: #213d21; | ||||
|         --color-maze-cell-solution-highlight: #3d6e3d; | ||||
|         --color-maze-cell-user: #ffcc00; | ||||
|         --color-maze-cell-user-highlight: #ffdd22; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     color: var(--color-foreground); | ||||
|     background: var(--color-background); | ||||
|     font-family: "Cantarell", Arial, Helvetica, sans-serif; | ||||
|     -webkit-font-smoothing: antialiased; | ||||
|     -moz-osx-font-smoothing: grayscale; | ||||
| } | ||||
| 
 | ||||
| input, select { | ||||
|     background-color: var(--color-background); | ||||
|     border: 1px solid var(--color-border); | ||||
|     color: var(--color-foreground); | ||||
| } | ||||
| 
 | ||||
| input:hover, input:focus, select:hover, select:focus { | ||||
|     background-color: var(--color-background-highlight); | ||||
|     border-color: var(--color-border-highlight); | ||||
|     color: var(--color-foreground-highlight); | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/app/input-form.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/app/input-form.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| form { | ||||
|     display: flex; | ||||
|     flex-flow: column; | ||||
|     align-items: center; | ||||
| } | ||||
| 
 | ||||
| input, select { | ||||
|     background-color: var(--color-background); | ||||
|     border: 1px solid var(--color-border); | ||||
|     color: var(--color-foreground); | ||||
| } | ||||
| 
 | ||||
| input:hover, input:focus, select:hover, select:focus { | ||||
|     background-color: var(--color-background-highlight); | ||||
|     border-color: var(--color-border-highlight); | ||||
|     color: var(--color-foreground-highlight); | ||||
| } | ||||
| 
 | ||||
| input:invalid { | ||||
|     border-color: var(--color-form-error); | ||||
| } | ||||
							
								
								
									
										14
									
								
								src/app/input-form.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/app/input-form.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| .inputform { | ||||
|     display: grid; | ||||
|     grid-row-gap: 0.5em; | ||||
|     grid-template-columns: 7em 14em auto; | ||||
| } | ||||
| 
 | ||||
| .submitbutton { | ||||
|     background-color: #fc0; | ||||
|     border: 1px solid var(--color-foreground); | ||||
|     padding: 0.5em; | ||||
|     border-radius: 0.5em; | ||||
|     margin-top: 2em; | ||||
|     width: 11em; | ||||
| } | ||||
							
								
								
									
										118
									
								
								src/app/input-form.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/app/input-form.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,118 @@ | |||
| import {ActionDispatch, FormEvent, useState} from 'react'; | ||||
| import ValidatingInputNumberField, {ValidatorFunction} from "./validating-input-number-field.tsx"; | ||||
| import {Action, actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts"; | ||||
| import styles from "./input-form.module.css"; | ||||
| import "./input-form.css"; | ||||
| import {State} from "@/app/state/state.ts"; | ||||
| 
 | ||||
| export default function InputForm({state, dispatch}: { | ||||
|     state: State, | ||||
|     dispatch: ActionDispatch<[Action]> | ||||
| }) { | ||||
|     const [width, setWidth] = useState(10); | ||||
|     const [height, setHeight] = useState(10); | ||||
|     const [id, setId] = useState<number>(); | ||||
|     const [algorithm, setAlgorithm] = useState('wilson'); | ||||
| 
 | ||||
|     const handleSubmit = (e: FormEvent) => { | ||||
|         e.preventDefault(); | ||||
|         dispatch(actionStartedLoading()); | ||||
|         const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}&algorithm=${algorithm}`; | ||||
|         fetch(url) | ||||
|             .then(response => response.json()) | ||||
|             // .then(result => new Promise(resolve => setTimeout(resolve, 600, result)))
 | ||||
|             .then(result => { | ||||
|                 dispatch(actionLoadedMaze(result)); | ||||
|             }) | ||||
|             .catch(reason => { | ||||
|                 console.error("Failed to fetch maze data.", reason); | ||||
|                 dispatch(actionLoadingFailed(reason)); | ||||
|             }); | ||||
|     }; | ||||
|     const validateWidthHeightInput: ValidatorFunction<string, number> = value => { | ||||
|         const numberValue = Number(value); | ||||
|         if (isNaN(numberValue) || "" === value || (Math.floor(numberValue) !== numberValue)) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be an integer greater than 1." | ||||
|             }; | ||||
|         } | ||||
|         if (numberValue < 1) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be greater than 1." | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             valid: true, | ||||
|             value: numberValue | ||||
|         }; | ||||
|     }; | ||||
|     const validateIdInput: ValidatorFunction<string, number> = value => { | ||||
|         if ("" === value) { | ||||
|             return { | ||||
|                 valid: true | ||||
|             }; | ||||
|         } | ||||
|         const numberValue = Number(value); | ||||
|         // FIXME doesn't handle strings with characters correctly (e.g. "asdf" yields an empty value, due to "type=number").
 | ||||
|         if (isNaN(numberValue) || Math.floor(numberValue) !== numberValue) { | ||||
|             return { | ||||
|                 valid: false, | ||||
|                 message: "Must be empty or an integer" | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             valid: true, | ||||
|             value: numberValue | ||||
|         } | ||||
|     }; | ||||
|     return ( | ||||
|         <form onSubmit={handleSubmit}> | ||||
|             <div className={styles.inputform}> | ||||
|                 <ValidatingInputNumberField id={"width"} | ||||
|                                             label={"Width"} | ||||
|                                             value={width} | ||||
|                                             constraints={{ | ||||
|                                                 min: 2 | ||||
|                                             }} | ||||
|                                             validatorFn={validateWidthHeightInput} | ||||
|                                             disabled={state.loading} | ||||
|                                             onChange={setWidth} | ||||
|                 /> | ||||
|                 <ValidatingInputNumberField id={"height"} | ||||
|                                             label={"Height"} | ||||
|                                             value={height} | ||||
|                                             constraints={{ | ||||
|                                                 min: 2 | ||||
|                                             }} | ||||
|                                             validatorFn={validateWidthHeightInput} | ||||
|                                             disabled={state.loading} | ||||
|                                             onChange={setHeight} | ||||
|                 /> | ||||
|                 <ValidatingInputNumberField id={"id"} | ||||
|                                             label={"ID (optional)"} | ||||
|                                             value={id} | ||||
|                                             validatorFn={validateIdInput} | ||||
|                                             disabled={state.loading} | ||||
|                                             onChange={setId} | ||||
|                 /> | ||||
|                 <label htmlFor="algorithm">Algorithm:</label> | ||||
|                 <select id={"algorithm"} | ||||
|                         value={algorithm} | ||||
|                         disabled={state.loading} | ||||
|                         onChange={e => setAlgorithm(e.target.value)}> | ||||
|                     <option value="wilson">Wilson</option> | ||||
|                     <option value="random">Random Depth First</option> | ||||
|                 </select> | ||||
|             </div> | ||||
|             <button type={"submit"} | ||||
|                     disabled={state.loading | ||||
|                         || isNaN(width) | ||||
|                         || isNaN(height) | ||||
|                     } | ||||
|                     className={styles.submitbutton}>{state.loading ? "Loading ..." : "Create Maze!"} | ||||
|             </button> | ||||
|         </form> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/app/layout.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/layout.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import type {Metadata} from "next"; | ||||
| import "./globals.css"; | ||||
| 
 | ||||
| export const metadata: Metadata = { | ||||
|     title: "A-Maze-R! Create your own Maze!", | ||||
|     description: "A Maze Generator by fritteli", | ||||
| }; | ||||
| 
 | ||||
| export default function RootLayout({children}: Readonly<{ | ||||
|     children: React.ReactNode; | ||||
| }>) { | ||||
|     return ( | ||||
|         <html lang="en"> | ||||
|         <body> | ||||
|         {children} | ||||
|         </body> | ||||
|         </html> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/app/maze.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/maze.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| .maze { | ||||
|     display: table; | ||||
|     border-collapse: collapse; | ||||
|     background-color: var(--color-maze-background); | ||||
| } | ||||
| 
 | ||||
| .row { | ||||
|     display: table-row; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/app/maze.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/app/maze.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import Cell from "./cell.tsx"; | ||||
| import styles from "./maze.module.css"; | ||||
| import {State} from "@/app/state/state.ts"; | ||||
| import {ActionDispatch, JSX} from "react"; | ||||
| import {Action} from "@/app/state/action.ts"; | ||||
| 
 | ||||
| export default function Maze({state, dispatch}: | ||||
|                              { | ||||
|                                  state: State, | ||||
|                                  dispatch: ActionDispatch<[Action]> | ||||
|                              }) { | ||||
|     if (!state.maze) { | ||||
|         return <div>No valid maze.</div> | ||||
|     } | ||||
| 
 | ||||
|     const maze: JSX.Element[] = []; | ||||
|     for (let y = 0; y < state.maze.height; y++) { | ||||
|         const row: JSX.Element[] = []; | ||||
|         for (let x = 0; x < state.maze.width; x++) { | ||||
|             row.push(<Cell key={`${x}x${y}`} x={x} y={y} state={state} dispatch={dispatch}/>) | ||||
|         } | ||||
|         maze.push(<div key={`r${y}`} className={styles.row}>{row}</div>); | ||||
|     } | ||||
|     return ( | ||||
|         <div className={styles.maze}> | ||||
|             {maze} | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										10
									
								
								src/app/message-banner.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/message-banner.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | |||
| .banner { | ||||
|     height: 2em; | ||||
| } | ||||
| .empty { | ||||
| } | ||||
| 
 | ||||
| .message { | ||||
|     border: 1px solid red; | ||||
|     background-color: #ff000044; | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/app/message-banner.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/message-banner.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| import {Action, actionClosedMessageBanner} from "./state/action.ts"; | ||||
| import styles from "./message-banner.module.css"; | ||||
| import {State} from "@/app/state/state.ts"; | ||||
| import {ActionDispatch} from "react"; | ||||
| 
 | ||||
| export default function MessageBanner({state, dispatch}: | ||||
|                                       { | ||||
|                                           state: State; | ||||
|                                           dispatch: ActionDispatch<[Action]> | ||||
|                                       }) { | ||||
|     function handleClose() { | ||||
|         dispatch(actionClosedMessageBanner()); | ||||
|     } | ||||
| 
 | ||||
|     if (!!state.errorMessage) { | ||||
|         return ( | ||||
|             <div className={styles.banner + " " + styles.message}> | ||||
|                 {state.errorMessage} | ||||
|                 <button onClick={handleClose}>Dismiss message</button> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
|     return (<div className={styles.banner + " " + styles.empty}></div>); | ||||
| } | ||||
|  | @ -1,4 +1,4 @@ | |||
| import Coordinates from "./Coordinates"; | ||||
| import Coordinates from "./coordinates.ts"; | ||||
| 
 | ||||
| export default interface Maze { | ||||
|     id: string, | ||||
|  | @ -6,6 +6,7 @@ export default interface Maze { | |||
|     height: number, | ||||
|     start: Coordinates, | ||||
|     end: Coordinates, | ||||
|     algorithm: string, | ||||
|     grid: MazeCell[][] | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										179
									
								
								src/app/page.module.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/app/page.module.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,179 @@ | |||
| .page { | ||||
| } | ||||
| 
 | ||||
| .page h1.mainheading, .page h2.mainheading { | ||||
|     text-align: center; | ||||
| } | ||||
| 
 | ||||
| .page h2.mainheading { | ||||
|     font-size: medium; | ||||
| } | ||||
| 
 | ||||
| /*.page {*/ | ||||
| /*  --gray-rgb: 0, 0, 0;*/ | ||||
| /*  --gray-alpha-200: rgba(var(--gray-rgb), 0.08);*/ | ||||
| /*  --gray-alpha-100: rgba(var(--gray-rgb), 0.05);*/ | ||||
| 
 | ||||
| /*  --button-primary-hover: #383838;*/ | ||||
| /*  --button-secondary-hover: #f2f2f2;*/ | ||||
| 
 | ||||
| /*  display: grid;*/ | ||||
| /*  grid-template-rows: 20px 1fr 20px;*/ | ||||
| /*  align-items: center;*/ | ||||
| /*  justify-items: center;*/ | ||||
| /*  min-height: 100svh;*/ | ||||
| /*  padding: 80px;*/ | ||||
| /*  gap: 64px;*/ | ||||
| /*  font-family: var(--font-geist-sans);*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*@media (prefers-color-scheme: dark) {*/ | ||||
| /*  .page {*/ | ||||
| /*    --gray-rgb: 255, 255, 255;*/ | ||||
| /*    --gray-alpha-200: rgba(var(--gray-rgb), 0.145);*/ | ||||
| /*    --gray-alpha-100: rgba(var(--gray-rgb), 0.06);*/ | ||||
| 
 | ||||
| /*    --button-primary-hover: #ccc;*/ | ||||
| /*    --button-secondary-hover: #1a1a1a;*/ | ||||
| /*  }*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.main {*/ | ||||
| /*  display: flex;*/ | ||||
| /*  flex-direction: column;*/ | ||||
| /*  gap: 32px;*/ | ||||
| /*  grid-row-start: 2;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.main ol {*/ | ||||
| /*  font-family: var(--font-geist-mono);*/ | ||||
| /*  padding-left: 0;*/ | ||||
| /*  margin: 0;*/ | ||||
| /*  font-size: 14px;*/ | ||||
| /*  line-height: 24px;*/ | ||||
| /*  letter-spacing: -0.01em;*/ | ||||
| /*  list-style-position: inside;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.main li:not(:last-of-type) {*/ | ||||
| /*  margin-bottom: 8px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.main code {*/ | ||||
| /*  font-family: inherit;*/ | ||||
| /*  background: var(--gray-alpha-100);*/ | ||||
| /*  padding: 2px 4px;*/ | ||||
| /*  border-radius: 4px;*/ | ||||
| /*  font-weight: 600;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.ctas {*/ | ||||
| /*  display: flex;*/ | ||||
| /*  gap: 16px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.ctas a {*/ | ||||
| /*  appearance: none;*/ | ||||
| /*  border-radius: 128px;*/ | ||||
| /*  height: 48px;*/ | ||||
| /*  padding: 0 20px;*/ | ||||
| /*  border: none;*/ | ||||
| /*  border: 1px solid transparent;*/ | ||||
| /*  transition:*/ | ||||
| /*    background 0.2s,*/ | ||||
| /*    color 0.2s,*/ | ||||
| /*    border-color 0.2s;*/ | ||||
| /*  cursor: pointer;*/ | ||||
| /*  display: flex;*/ | ||||
| /*  align-items: center;*/ | ||||
| /*  justify-content: center;*/ | ||||
| /*  font-size: 16px;*/ | ||||
| /*  line-height: 20px;*/ | ||||
| /*  font-weight: 500;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*a.primary {*/ | ||||
| /*  background: var(--foreground);*/ | ||||
| /*  color: var(--background);*/ | ||||
| /*  gap: 8px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*a.secondary {*/ | ||||
| /*  border-color: var(--gray-alpha-200);*/ | ||||
| /*  min-width: 180px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.footer {*/ | ||||
| /*  grid-row-start: 3;*/ | ||||
| /*  display: flex;*/ | ||||
| /*  gap: 24px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.footer a {*/ | ||||
| /*  display: flex;*/ | ||||
| /*  align-items: center;*/ | ||||
| /*  gap: 8px;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*.footer img {*/ | ||||
| /*  flex-shrink: 0;*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*!* Enable hover only on non-touch devices *!*/ | ||||
| /*@media (hover: hover) and (pointer: fine) {*/ | ||||
| /*  a.primary:hover {*/ | ||||
| /*    background: var(--button-primary-hover);*/ | ||||
| /*    border-color: transparent;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  a.secondary:hover {*/ | ||||
| /*    background: var(--button-secondary-hover);*/ | ||||
| /*    border-color: transparent;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .footer a:hover {*/ | ||||
| /*    text-decoration: underline;*/ | ||||
| /*    text-underline-offset: 4px;*/ | ||||
| /*  }*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*@media (max-width: 600px) {*/ | ||||
| /*  .page {*/ | ||||
| /*    padding: 32px;*/ | ||||
| /*    padding-bottom: 80px;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .main {*/ | ||||
| /*    align-items: center;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .main ol {*/ | ||||
| /*    text-align: center;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .ctas {*/ | ||||
| /*    flex-direction: column;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .ctas a {*/ | ||||
| /*    font-size: 14px;*/ | ||||
| /*    height: 40px;*/ | ||||
| /*    padding: 0 16px;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  a.secondary {*/ | ||||
| /*    min-width: auto;*/ | ||||
| /*  }*/ | ||||
| 
 | ||||
| /*  .footer {*/ | ||||
| /*    flex-wrap: wrap;*/ | ||||
| /*    align-items: center;*/ | ||||
| /*    justify-content: center;*/ | ||||
| /*  }*/ | ||||
| /*}*/ | ||||
| 
 | ||||
| /*@media (prefers-color-scheme: dark) {*/ | ||||
| /*  .logo {*/ | ||||
| /*    filter: invert();*/ | ||||
| /*  }*/ | ||||
| /*}*/ | ||||
|  | @ -1,23 +1,29 @@ | |||
| import {useReducer} from 'react'; | ||||
| import Maze from "./Maze.tsx"; | ||||
| import InputForm from "./InputForm.tsx"; | ||||
| import reduce from "./state/reducer.ts"; | ||||
| import MessageBanner from "./MessageBanner.tsx"; | ||||
| import {INITIAL_STATE} from "./state/state.ts"; | ||||
| import {actionToggledShowSolution} from "./state/action.ts"; | ||||
| 'use client' | ||||
| 
 | ||||
| export default function App() { | ||||
| import styles from "./page.module.css"; | ||||
| import {useReducer} from "react"; | ||||
| import reduce from "./state/reducer.ts"; | ||||
| import {INITIAL_STATE} from "./state/state.ts"; | ||||
| import MessageBanner from "./message-banner.tsx"; | ||||
| import InputForm from "./input-form.tsx"; | ||||
| import {actionToggledShowSolution} from "./state/action.ts"; | ||||
| import Maze from "./maze.tsx"; | ||||
| 
 | ||||
| export default function Home() { | ||||
|     const [state, dispatch] = useReducer(reduce, INITIAL_STATE); | ||||
|     const hasValidMaze = !!state.maze; | ||||
|     return ( | ||||
|         <> | ||||
|         <div className={styles.page}> | ||||
|             <h1 className={styles.mainheading}>A-Maze-R! Create your own maze!</h1> | ||||
|             <h2 className={styles.mainheading}>A fun little maze generator written by fritteli</h2> | ||||
|             <MessageBanner state={state} | ||||
|                            dispatch={dispatch}/> | ||||
|             <InputForm state={state} | ||||
|                        dispatch={dispatch}/> | ||||
|             {hasValidMaze && | ||||
|                 <> | ||||
|                     <h1>The Maze ({state.maze.width}x{state.maze.height}, ID: {state.maze.id})</h1> | ||||
|                     <h1>The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm}, | ||||
|                         ID: {state.maze!.id})</h1> | ||||
|                     <input type={"checkbox"} | ||||
|                            onChange={(e) => { | ||||
|                                dispatch(actionToggledShowSolution(e.target.checked)); | ||||
|  | @ -27,6 +33,6 @@ export default function App() { | |||
|                           dispatch={dispatch}/> | ||||
|                 </> | ||||
|             } | ||||
|         </> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -1,9 +1,9 @@ | |||
| import Maze from "../model/Maze"; | ||||
| import Maze from "../model/maze.ts"; | ||||
| 
 | ||||
| export interface Action { | ||||
|     type: string, | ||||
| 
 | ||||
|     [key: string]: any | ||||
|     [key: string]: boolean | number | string | object | null | undefined; | ||||
| } | ||||
| 
 | ||||
| export const ID_ACTION_STARTED_LOADING = 'started_loading'; | ||||
|  | @ -9,8 +9,9 @@ import { | |||
|     ID_ACTION_STARTED_LOADING, | ||||
|     ID_ACTION_TOGGLED_SHOW_SOLUTION | ||||
| } from "./action.ts"; | ||||
| import Maze from "@/app/model/maze.ts"; | ||||
| 
 | ||||
| export default function reduce(state: State, action: Action) { | ||||
| export default function reduce(state: State, action: Action): State { | ||||
|     switch (action.type) { | ||||
|         case ID_ACTION_STARTED_LOADING: { | ||||
|             return { | ||||
|  | @ -24,7 +25,7 @@ export default function reduce(state: State, action: Action) { | |||
|             return { | ||||
|                 ...state, | ||||
|                 loading: false, | ||||
|                 maze: action.maze, | ||||
|                 maze: action.maze as Maze, | ||||
|                 userPath: [] | ||||
|             } | ||||
|         } | ||||
|  | @ -38,7 +39,7 @@ export default function reduce(state: State, action: Action) { | |||
|         case ID_ACTION_TOGGLED_SHOW_SOLUTION: { | ||||
|             return { | ||||
|                 ...state, | ||||
|                 showSolution: action.value | ||||
|                 showSolution: action.value as boolean | ||||
|             } | ||||
|         } | ||||
|         case ID_ACTION_CLOSED_MESSAGE_BANNER: { | ||||
|  | @ -49,7 +50,7 @@ export default function reduce(state: State, action: Action) { | |||
|         } | ||||
|         case ID_ACTION_CLICKED_CELL: { | ||||
|             // There's so much logic involved, externalize that into its own file.
 | ||||
|             return handleUserClicked(state, action.x, action.y); | ||||
|             return handleUserClicked(state, action.x as number, action.y as number); | ||||
|         } | ||||
|         default: { | ||||
|             throw new Error(`Unknown action: ${action.type}`); | ||||
|  | @ -1,5 +1,5 @@ | |||
| import Coordinates from "../model/Coordinates"; | ||||
| import Maze from "../model/Maze"; | ||||
| import Coordinates from "../model/coordinates.ts"; | ||||
| import Maze from "../model/maze.ts"; | ||||
| 
 | ||||
| export interface State { | ||||
|     errorMessage: string | null, | ||||
|  | @ -1,15 +1,16 @@ | |||
| import {State} from "./state"; | ||||
| import Coordinates from "../model/Coordinates"; | ||||
| import {MazeCell} from "../model/Maze"; | ||||
| import Coordinates from "../model/coordinates.ts"; | ||||
| import {MazeCell} from "../model/maze.ts"; | ||||
| 
 | ||||
| export default function handleUserClicked(state: State, x: number, y: number): State { | ||||
|     if (isClickAllowed(x, y, state)) { | ||||
|         const maze = state.maze!; | ||||
|         // Okay, we clicked a cell that's adjacent to the end of the userpath (or which IS the end of the userpath)
 | ||||
|         // and that's not blocked by a wall. Now let's see.
 | ||||
|         if (-1 === state.userPath.findIndex(step => step.x === x && step.y === y)) { | ||||
|             // The clicked cell is not yet part of the userpath --> add it.
 | ||||
|             // If it's the end tile, also show a congratulation message
 | ||||
|             const showMessage = x === state.maze.end.x && y === state.maze.end.y; | ||||
|             const showMessage = x === maze.end.x && y === maze.end.y; | ||||
|             return { | ||||
|                 ...state, | ||||
|                 userPath: [...state.userPath, {x, y}], | ||||
|  | @ -17,7 +18,7 @@ export default function handleUserClicked(state: State, x: number, y: number): S | |||
|             }; | ||||
|         } else { | ||||
|             // The clicked cell IS part of the userpath. Is it the last cell of it?
 | ||||
|             const lastCoordsFromUserPath = getLastCoordsFromUserPath(state); | ||||
|             const lastCoordsFromUserPath = getLastCoordsFromUserPath(state)!; | ||||
|             if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) { | ||||
|                 // Yes, it's the last cell of the userpath --> remove it.
 | ||||
|                 return { | ||||
|  | @ -36,7 +37,7 @@ function isClickAllowed(x: number, y: number, state: State): boolean { | |||
|     const lastCoordsFromUserPath = getLastCoordsFromUserPath(state); | ||||
|     if (!lastCoordsFromUserPath) { | ||||
|         // when nothing has been marked yet, we can only toggle the starting position
 | ||||
|         return x === state.maze.start.x && y === state.maze.start.y; | ||||
|         return x === state.maze!.start.x && y === state.maze!.start.y; | ||||
|     } | ||||
|     if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) { | ||||
|         // toggling the last position in the path is always allowed
 | ||||
|  | @ -70,7 +71,7 @@ function isClickAllowed(x: number, y: number, state: State): boolean { | |||
| } | ||||
| 
 | ||||
| function getCellAt(coords: Coordinates, state: State): MazeCell { | ||||
|     return state.maze.grid[coords.y][coords.x]; | ||||
|     return state.maze!.grid[coords.y][coords.x]; | ||||
| } | ||||
| 
 | ||||
| function getLastCoordsFromUserPath(state: State): Coordinates | null { | ||||
							
								
								
									
										60
									
								
								src/app/validating-input-number-field.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/app/validating-input-number-field.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import React, {ChangeEventHandler, useState} from 'react'; | ||||
| 
 | ||||
| export default function ValidatingInputNumberField({ | ||||
|                                                        id, | ||||
|                                                        label, | ||||
|                                                        value = 0, | ||||
|                                                        constraints = undefined, | ||||
|                                                        validatorFn = (value) => { | ||||
|                                                            return {valid: true, value: Number(value)}; | ||||
|                                                        }, | ||||
|                                                        disabled = false, | ||||
|                                                        onChange = () => { | ||||
|                                                        } | ||||
|                                                    }: | ||||
|                                                    { | ||||
|                                                        id: string; | ||||
|                                                        label: string; | ||||
|                                                        value?: number; | ||||
|                                                        constraints?: { min?: number; max?: number; }; | ||||
|                                                        validatorFn: ValidatorFunction<string, number>; | ||||
|                                                        disabled: boolean; | ||||
|                                                        onChange: (v: number) => void; | ||||
|                                                    }) { | ||||
|     const [error, setError] = useState<string>(); | ||||
| 
 | ||||
|     const handleValueChange: ChangeEventHandler<HTMLInputElement> = (e) => { | ||||
|         const value = e.target.value; | ||||
|         const validation = validatorFn(value); | ||||
|         if (!validation.valid) { | ||||
|             setError(validation.message); | ||||
|         } else { | ||||
|             setError(undefined); | ||||
|         } | ||||
|         if (validation.value) { | ||||
|             onChange(validation.value); | ||||
|         } | ||||
|     }; | ||||
|     return ( | ||||
|         <> | ||||
|             <label htmlFor={id}>{label}: </label> | ||||
|             <input id={id} | ||||
|                    type={"number"} | ||||
|                    onChange={handleValueChange} | ||||
|                    value={value || ""} | ||||
|                    min={constraints?.min} | ||||
|                    max={constraints?.max} | ||||
|                    disabled={disabled} | ||||
|             /> | ||||
|             <span>{error}</span> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| export interface Validation<T> { | ||||
|     valid: boolean; | ||||
|     message?: string; | ||||
|     value?: T; | ||||
| } | ||||
| 
 | ||||
| export type ValidatorFunction<I, T> = (v: I) => Validation<T>; | ||||
|  | @ -1,52 +0,0 @@ | |||
| // In production, we register a service worker to serve assets from local cache.
 | ||||
| 
 | ||||
| // This lets the app load faster on subsequent visits in production, and gives
 | ||||
| // it offline capabilities. However, it also means that developers (and users)
 | ||||
| // will only see deployed updates on the "N+1" visit to a page, since previously
 | ||||
| // cached resources are updated in the background.
 | ||||
| 
 | ||||
| // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
 | ||||
| // This link also includes instructions on opting out of this behavior.
 | ||||
| 
 | ||||
| export default function register() { | ||||
|   if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { | ||||
|     window.addEventListener('load', () => { | ||||
|       const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; | ||||
|       navigator.serviceWorker | ||||
|         .register(swUrl) | ||||
|         .then(registration => { | ||||
|           // eslint-disable-next-line no-param-reassign
 | ||||
|           registration.onupdatefound = () => { | ||||
|             const installingWorker = registration.installing; | ||||
|             installingWorker.onstatechange = () => { | ||||
|               if (installingWorker.state === 'installed') { | ||||
|                 if (navigator.serviceWorker.controller) { | ||||
|                   // At this point, the old content will have been purged and
 | ||||
|                   // the fresh content will have been added to the cache.
 | ||||
|                   // It's the perfect time to display a "New content is
 | ||||
|                   // available; please refresh." message in your web app.
 | ||||
|                   console.log('New content is available; please refresh.'); // eslint-disable-line no-console
 | ||||
|                 } else { | ||||
|                   // At this point, everything has been precached.
 | ||||
|                   // It's the perfect time to display a
 | ||||
|                   // "Content is cached for offline use." message.
 | ||||
|                   console.log('Content is cached for offline use.'); // eslint-disable-line no-console
 | ||||
|                 } | ||||
|               } | ||||
|             }; | ||||
|           }; | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           console.error('Error during service worker registration:', error); | ||||
|         }); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function unregister() { | ||||
|   if ('serviceWorker' in navigator) { | ||||
|     navigator.serviceWorker.ready.then(registration => { | ||||
|       registration.unregister(); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | @ -1,12 +0,0 @@ | |||
| import {StrictMode} from "react"; | ||||
| import {createRoot, Root} from "react-dom/client"; | ||||
| import "./styles.css"; | ||||
| import App from "./App.tsx"; | ||||
| 
 | ||||
| 
 | ||||
| const root: Root = createRoot(document.getElementById("root")); | ||||
| root.render( | ||||
|     <StrictMode> | ||||
|         <App/> | ||||
|     </StrictMode> | ||||
| ); | ||||
|  | @ -1,9 +0,0 @@ | |||
| { | ||||
|   "dependencies": { | ||||
|     "react": "^18.0.0", | ||||
|     "react-dom": "^18.0.0", | ||||
|     "react-scripts": "^5.0.0" | ||||
|   }, | ||||
|   "main": "/index.js", | ||||
|   "devDependencies": {} | ||||
| } | ||||
|  | @ -1,11 +0,0 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>A-Maze-R</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										151
									
								
								src/styles.css
									
										
									
									
									
								
							
							
						
						
									
										151
									
								
								src/styles.css
									
										
									
									
									
								
							|  | @ -1,151 +0,0 @@ | |||
| * { | ||||
|     box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| div.maze { | ||||
|     display: table; | ||||
|     border-collapse: collapse; | ||||
| } | ||||
| 
 | ||||
| div.row { | ||||
|     display: table-row; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| div.cell { | ||||
|     display: table-cell; | ||||
|     border: 1px solid transparent; | ||||
|     height: 2em; | ||||
|     width: 2em; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| div.cell.solution { | ||||
|     background-color: lightgray; | ||||
| } | ||||
| 
 | ||||
| div.cell.top { | ||||
|     border-top-color: #000; | ||||
| } | ||||
| 
 | ||||
| div.cell.right { | ||||
|     border-right-color: #000; | ||||
| } | ||||
| 
 | ||||
| div.cell.bottom { | ||||
|     border-bottom-color: #000; | ||||
| } | ||||
| 
 | ||||
| div.cell.left { | ||||
|     border-left-color: #000; | ||||
| } | ||||
| 
 | ||||
| div.cell.user { | ||||
|     background-color: hotpink; | ||||
| } | ||||
| 
 | ||||
| div.cell.user2 { | ||||
|     border-radius: 0.5em; | ||||
| } | ||||
| 
 | ||||
| div.cell.solution.user { | ||||
|     background-color: darkred; | ||||
| } | ||||
| 
 | ||||
| div.message-banner { | ||||
|     border: 1px solid red; | ||||
|     background-color: #ff000044; | ||||
| } | ||||
| 
 | ||||
| input:invalid { | ||||
|     border-color: #f00; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     font-family: sans-serif; | ||||
|     margin: 20px; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| h1 { | ||||
|     margin-top: 0; | ||||
|     font-size: 22px; | ||||
| } | ||||
| 
 | ||||
| h2 { | ||||
|     margin-top: 0; | ||||
|     font-size: 20px; | ||||
| } | ||||
| 
 | ||||
| h3 { | ||||
|     margin-top: 0; | ||||
|     font-size: 18px; | ||||
| } | ||||
| 
 | ||||
| h4 { | ||||
|     margin-top: 0; | ||||
|     font-size: 16px; | ||||
| } | ||||
| 
 | ||||
| h5 { | ||||
|     margin-top: 0; | ||||
|     font-size: 14px; | ||||
| } | ||||
| 
 | ||||
| h6 { | ||||
|     margin-top: 0; | ||||
|     font-size: 12px; | ||||
| } | ||||
| 
 | ||||
| code { | ||||
|     font-size: 1.2em; | ||||
| } | ||||
| 
 | ||||
| ul { | ||||
|     padding-left: 20px; | ||||
| } | ||||
| 
 | ||||
| * { | ||||
|     box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| body { | ||||
|     font-family: sans-serif; | ||||
|     margin: 20px; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| .square { | ||||
|     background: #fff; | ||||
|     border: 1px solid #999; | ||||
|     float: left; | ||||
|     font-size: 24px; | ||||
|     font-weight: bold; | ||||
|     line-height: 34px; | ||||
|     height: 34px; | ||||
|     margin-right: -1px; | ||||
|     margin-top: -1px; | ||||
|     padding: 0; | ||||
|     text-align: center; | ||||
|     width: 34px; | ||||
| } | ||||
| 
 | ||||
| .board-row:after { | ||||
|     clear: both; | ||||
|     content: ''; | ||||
|     display: table; | ||||
| } | ||||
| 
 | ||||
| .status { | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
| 
 | ||||
| .game { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
| 
 | ||||
| .game-info { | ||||
|     margin-left: 20px; | ||||
| } | ||||
							
								
								
									
										31
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								tsconfig.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2017", | ||||
|     "lib": ["dom", "dom.iterable", "esnext"], | ||||
|     "allowJs": true, | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "skipLibCheck": true, | ||||
|     "strict": true, | ||||
|     "noEmit": true, | ||||
|     "esModuleInterop": true, | ||||
|     "module": "esnext", | ||||
|     "moduleResolution": "bundler", | ||||
|     "resolveJsonModule": true, | ||||
|     "isolatedModules": true, | ||||
|     "jsx": "preserve", | ||||
|     "incremental": true, | ||||
|     "plugins": [ | ||||
|       { | ||||
|         "name": "next" | ||||
|       } | ||||
|     ], | ||||
|     "paths": { | ||||
|       "@/*": ["./src/*"] | ||||
|     } | ||||
|   }, | ||||
|   "include": [ | ||||
|     "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], | ||||
|   "exclude": [ | ||||
|     "a-maze-r/node_modules" | ||||
|   ] | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue