Some fiddling, but also version upgrades and a new button for downloading a pdf version of the current maze.
Some checks reported errors
continuous-integration/drone/push Build encountered an error

This commit is contained in:
Manuel Friedli 2026-01-24 05:30:33 +01:00
parent 79467e29f2
commit b6359bcb5d
Signed by: manuel
GPG key ID: 41D08ABA75634DA1
21 changed files with 2002 additions and 927 deletions

1
next-env.d.ts vendored
View file

@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

1963
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,17 +9,18 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"react": "^19.0.0", "next": "^16.1.4",
"react-dom": "^19.0.0", "react": "^19.2.3",
"next": "15.1.2" "react-dom": "^19.2.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.1.2", "eslint-config-next": "^16.1.4",
"@eslint/eslintrc": "^3" "sass": "^1.85.1",
"typescript": "^5"
} }
} }

View file

@ -1,109 +0,0 @@
.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);
}
.userSELF {
background: radial-gradient(
ellipse 16% 16% at center,
var(--color-maze-cell-user) 0,
var(--color-maze-cell-user) 100%,
#0000 100%
);
}
.userSELF:hover {
background: radial-gradient(
ellipse 33% 33% at center,
var(--color-maze-cell-user) 0,
var(--color-maze-cell-user) 80%,
#0000 100%
);
}
.marker {
display: inline-block;
position: absolute;
}
.marker:hover {
background: #fc08;
}
.userUP .marker.UP {
height: 50%;
width: 100%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.userRIGHT .marker.RIGHT {
height: 100%;
width: 50%;
left: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.userDOWN .marker.DOWN {
height: 50%;
width: 100%;
top: 50%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.userLEFT .marker.LEFT {
height: 100%;
width: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}

View file

@ -1,12 +0,0 @@
.cell {
display: table-cell;
border: 1px solid transparent;
height: 2em;
width: 2em;
padding: 0;
position: relative;
}
.cell:hover {
background-color: var(--color-background-highlight);
}

12
src/app/cell.module.scss Normal file
View file

@ -0,0 +1,12 @@
.cell {
display: table-cell;
border: 1px solid transparent;
height: 2em;
width: 2em;
padding: 0;
position: relative;
&:hover {
background-color: var(--color-background-highlight);
}
}

186
src/app/cell.scss Normal file
View file

@ -0,0 +1,186 @@
.solution {
background-color: var(--color-maze-cell-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);
}
.userSELF {
background: radial-gradient(
ellipse 16% 16% at center,
var(--color-maze-cell-user) 0,
var(--color-maze-cell-user) 100%,
#0000 100%
);
&.solution {
background: radial-gradient(
ellipse 16% 16% at center,
var(--color-maze-cell-user-solution) 0,
var(--color-maze-cell-user-solution) 100%,
#0000 100%
);
&:hover {
background: radial-gradient(
ellipse 33% 33% at center,
var(--color-maze-cell-user-solution) 0,
var(--color-maze-cell-user-solution) 80%,
#0000 100%
);
}
}
&:hover {
background: radial-gradient(
ellipse 33% 33% at center,
var(--color-maze-cell-user) 0,
var(--color-maze-cell-user) 80%,
#0000 100%
);
}
}
.marker {
display: inline-block;
position: absolute;
&:hover {
background: #fc08;
}
}
.userUP .marker.UP {
height: 50%;
width: 100%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.solution.userUP .marker.UP {
height: 50%;
width: 100%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user-solution) 33%,
var(--color-maze-cell-user-solution) 66%,
#0000 66%,
#0000 100%
);
}
.userRIGHT .marker.RIGHT {
height: 100%;
width: 50%;
left: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.solution.userRIGHT .marker.RIGHT {
height: 100%;
width: 50%;
left: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user-solution) 33%,
var(--color-maze-cell-user-solution) 66%,
#0000 66%,
#0000 100%
);
}
.userDOWN .marker.DOWN {
height: 50%;
width: 100%;
top: 50%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.solution.userDOWN .marker.DOWN {
height: 50%;
width: 100%;
top: 50%;
background: linear-gradient(
90deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user-solution) 33%,
var(--color-maze-cell-user-solution) 66%,
#0000 66%,
#0000 100%
);
}
.userLEFT .marker.LEFT {
height: 100%;
width: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user) 33%,
var(--color-maze-cell-user) 66%,
#0000 66%,
#0000 100%
);
}
.solution.userLEFT .marker.LEFT {
height: 100%;
width: 50%;
background: linear-gradient(
0deg,
#0000 0,
#0000 33%,
var(--color-maze-cell-user-solution) 33%,
var(--color-maze-cell-user-solution) 66%,
#0000 66%,
#0000 100%
);
}

View file

@ -1,8 +1,8 @@
import {MazeCell} from "./model/maze.ts"; import {MazeCell} from "./model/maze.ts";
import Coordinates from "./model/coordinates.ts"; import Coordinates from "./model/coordinates.ts";
import {Action, actionClickedCell} from "./state/action.ts"; import {Action, actionClickedCell} from "./state/action.ts";
import styles from "./cell.module.css"; import styles from "./cell.module.scss";
import "./cell.css"; import "./cell.scss";
import {State} from "./state/state.ts"; import {State} from "./state/state.ts";
import {ActionDispatch} from "react"; import {ActionDispatch} from "react";

View file

@ -12,7 +12,9 @@
--color-maze-cell-solution: #b1d5b1; --color-maze-cell-solution: #b1d5b1;
--color-maze-cell-solution-highlight: #b9e8b9; --color-maze-cell-solution-highlight: #b9e8b9;
--color-maze-cell-user: #ffcc00; --color-maze-cell-user: #ffcc00;
--color-maze-cell-user-highlight: #ffdd22; --color-maze-cell-user-solution: #47e147;
--color-accent: #ffcc00;
--color-accent-inverse: #000000;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -29,7 +31,7 @@
--color-maze-cell-solution: #213d21; --color-maze-cell-solution: #213d21;
--color-maze-cell-solution-highlight: #3d6e3d; --color-maze-cell-solution-highlight: #3d6e3d;
--color-maze-cell-user: #ffcc00; --color-maze-cell-user: #ffcc00;
--color-maze-cell-user-highlight: #ffdd22; --color-maze-cell-user-solution: #00a421;
} }
} }

View file

@ -1,21 +0,0 @@
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);
}

View file

@ -5,8 +5,9 @@
} }
.submitbutton { .submitbutton {
background-color: #fc0; background-color: var(--color-accent);
border: 1px solid var(--color-foreground); border: 1px solid var(--color-foreground);
color: var(--color-accent-inverse);
padding: 0.5em; padding: 0.5em;
border-radius: 0.5em; border-radius: 0.5em;
margin-top: 2em; margin-top: 2em;

21
src/app/input-form.scss Normal file
View 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);
&:hover, &: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);
}

View file

@ -1,5 +1,4 @@
import {ActionDispatch, FormEvent, useState} from 'react'; import {ActionDispatch, FormEvent, useState} from 'react';
import ValidatingInputNumberField, {ValidatorFunction} from "./validating-input-number-field.tsx";
import { import {
Action, Action,
actionLoadedMaze, actionLoadedMaze,
@ -7,10 +6,14 @@ import {
actionStartedLoading, actionStartedLoading,
actionToggledShowSolution actionToggledShowSolution
} from "./state/action.ts"; } from "./state/action.ts";
import styles from "./input-form.module.css"; import styles from "./input-form.module.scss";
import "./input-form.css"; import "./input-form.scss";
import {State} from "@/app/state/state.ts"; import {State} from "@/app/state/state.ts";
import ValidatingInputBigIntField from "@/app/validating-input-bigint-field.tsx"; import {
ValidatingInputNumberField,
ValidatingInputRegExpField,
ValidatorFunction
} from "@/app/validating-input-field.tsx";
export default function InputForm({state, dispatch}: { export default function InputForm({state, dispatch}: {
state: State, state: State,
@ -18,7 +21,7 @@ export default function InputForm({state, dispatch}: {
}) { }) {
const [width, setWidth] = useState(10); const [width, setWidth] = useState(10);
const [height, setHeight] = useState(10); const [height, setHeight] = useState(10);
const [id, setId] = useState<bigint>(); const [id, setId] = useState<string>();
const [algorithm, setAlgorithm] = useState('wilson'); const [algorithm, setAlgorithm] = useState('wilson');
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
@ -36,7 +39,7 @@ export default function InputForm({state, dispatch}: {
dispatch(actionLoadingFailed(reason)); dispatch(actionLoadingFailed(reason));
}); });
}; };
const validateWidthHeightInput: ValidatorFunction<string, number> = value => { const validateSizeInput: ValidatorFunction<string, number> = value => {
const numberValue = Number(value); const numberValue = Number(value);
if (isNaN(numberValue) || "" === value || (Math.floor(numberValue) !== numberValue)) { if (isNaN(numberValue) || "" === value || (Math.floor(numberValue) !== numberValue)) {
return { return {
@ -55,24 +58,6 @@ export default function InputForm({state, dispatch}: {
value: numberValue value: numberValue
}; };
}; };
const validateIdInput: ValidatorFunction<string, bigint> = value => {
if ("" === value) {
return {
valid: true
};
}
const numberValue = BigInt(value);
if (numberValue.toString() !== value.trim()) {
return {
valid: false,
message: "Must be empty or an integer"
};
}
return {
valid: true,
value: numberValue
}
};
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className={styles.inputform}> <div className={styles.inputform}>
@ -82,7 +67,7 @@ export default function InputForm({state, dispatch}: {
constraints={{ constraints={{
min: 2 min: 2
}} }}
validatorFn={validateWidthHeightInput} validatorFn={validateSizeInput}
disabled={state.loading} disabled={state.loading}
onChange={setWidth} onChange={setWidth}
/> />
@ -92,16 +77,30 @@ export default function InputForm({state, dispatch}: {
constraints={{ constraints={{
min: 2 min: 2
}} }}
validatorFn={validateWidthHeightInput} validatorFn={validateSizeInput}
disabled={state.loading} disabled={state.loading}
onChange={setHeight} onChange={setHeight}
/> />
<ValidatingInputBigIntField id={"id"} {/*<ValidatingInputBigIntField id={"id"}*/}
{/* label={"ID (optional)"}*/}
{/* value={id}*/}
{/* validatorFn={validateIdInput}*/}
{/* disabled={state.loading}*/}
{/* onChange={setId}*/}
{/* constraints={{*/}
{/* min: -9223372036854775808n,*/}
{/* max: 9223372036854775807n*/}
{/* }}*/}
{/*/>*/}
<ValidatingInputRegExpField id={"id"}
label={"ID (optional)"} label={"ID (optional)"}
value={id} value={id}
validatorFn={validateIdInput}
disabled={state.loading} disabled={state.loading}
onChange={setId} onChange={setId}
constraints={[
/^[0-9a-fA-F]{0,16}$/
]}
placeholder={"Hex-Number (without 0x prefix)"}
/> />
<label htmlFor="algorithm">Algorithm:</label> <label htmlFor="algorithm">Algorithm:</label>
<select id={"algorithm"} <select id={"algorithm"}

View file

@ -1,5 +1,5 @@
import type {Metadata} from "next"; import type {Metadata} from "next";
import "./globals.css"; import "./globals.scss";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "A-Maze-R! Create your own Maze!", title: "A-Maze-R! Create your own Maze!",

View file

@ -1,179 +0,0 @@
.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();*/
/* }*/
/*}*/

22
src/app/page.module.scss Normal file
View file

@ -0,0 +1,22 @@
.page {
h1.mainheading, h2.mainheading {
text-align: center;
}
h2.mainheading {
font-size: medium;
}
a.downloadlink {
background-color: var(--color-accent);
border: 1px solid var(--color-foreground);
border-radius: 0.5em;
color: var(--color-accent-inverse);
display: block;
margin-bottom: 1em;
text-align: center;
text-decoration: none;
padding: 0.5em;
width: 11em;
}
}

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import styles from "./page.module.css"; import styles from "./page.module.scss";
import {useReducer} from "react"; import {useReducer} from "react";
import reduce from "./state/reducer.ts"; import reduce from "./state/reducer.ts";
import {INITIAL_STATE} from "./state/state.ts"; import {INITIAL_STATE} from "./state/state.ts";
@ -24,6 +24,12 @@ export default function Home() {
<> <>
<h1>The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm}, <h1>The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm},
ID: {state.maze!.id})</h1> ID: {state.maze!.id})</h1>
<a href={"https://manuel.friedli.info/labyrinth/create/pdffile?w="
+ state.maze!.width
+ "&h=" + state.maze!.height
+ "&id=" + state.maze!.id
+ "&a=" + state.maze!.algorithm}
className={styles.downloadlink}>Download as PDF file</a>
<input type={"checkbox"} <input type={"checkbox"}
checked={state.showSolution} checked={state.showSolution}
onChange={(e) => { onChange={(e) => {

View file

@ -1,60 +0,0 @@
import React, {ChangeEventHandler, useState} from 'react';
export default function ValidatingInputBigIntField({
id,
label,
value = 0n,
constraints = undefined,
validatorFn = (value) => {
return {valid: true, value: BigInt(value)};
},
disabled = false,
onChange = () => {
}
}:
{
id: string;
label: string;
value?: bigint;
constraints?: { min?: bigint; max?: bigint; };
validatorFn: ValidatorFunction<string, bigint>;
disabled: boolean;
onChange: (v: bigint) => 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 !== undefined) {
onChange(validation.value);
}
};
return (
<>
<label htmlFor={id}>{label}: </label>
<input id={id}
type={"number"}
onChange={handleValueChange}
value={value?.toString() || ""}
min={constraints?.min?.toString()}
max={constraints?.max?.toString()}
disabled={disabled}
/>
<span>{error}</span>
</>
);
}
export interface Validation<T> {
valid: boolean;
message?: string;
value?: T;
}
export type ValidatorFunction<I, T> = (v: I) => Validation<T>;

View file

@ -0,0 +1,173 @@
import React, {ChangeEventHandler, useState} from 'react';
export function ValidatingInputBigIntField({
id,
label,
value = 0n,
constraints = undefined,
validatorFn = (value) => {
return {valid: true, value: BigInt(value)};
},
disabled = false,
onChange = () => {
}
}:
{
id: string;
label: string;
value?: bigint;
constraints?: { min?: bigint; max?: bigint; };
validatorFn: ValidatorFunction<string, bigint>;
disabled: boolean;
onChange: (v: bigint) => 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 !== undefined) {
onChange(validation.value);
}
};
return (
<>
<label htmlFor={id}>{label}: </label>
<input id={id}
type={"number"}
onChange={handleValueChange}
value={value?.toString() || ""}
min={constraints?.min?.toString()}
max={constraints?.max?.toString()}
disabled={disabled}
/>
<span>{error}</span>
</>
);
}
export 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 function ValidatingInputRegExpField({
id,
label,
value = undefined,
constraints = undefined,
validatorFn = (value) => {
if (constraints === undefined) {
console.log("no constraints, returning VALID")
return {valid: true, value};
}
const allValid = constraints
.map(expr => expr.test(value))
.reduce((prev, curr) => prev && curr)
?? true;
console.log("valid?", allValid);
return {valid: allValid, value: allValid ? value : undefined};
},
disabled = false,
onChange = () => {
},
placeholder = ""
}:
{
id: string;
label: string;
value?: string;
constraints?: RegExp[];
validatorFn?: ValidatorFunction<string, string>;
disabled: boolean;
onChange: (v: string) => void;
placeholder?: string;
}) {
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 !== undefined) {
console.log("setting value to:", validation.value);
onChange(validation.value);
}
};
return (
<>
<label htmlFor={id}>{label}: </label>
<input id={id}
type={"text"}
placeholder={placeholder}
onChange={handleValueChange}
value={value?.toString() || ""}
disabled={disabled}
/>
<span>{error}</span>
</>
);
}
export interface Validation<T> {
valid: boolean;
message?: string;
value?: T;
}
export type ValidatorFunction<I, T> = (v: I) => Validation<T>;

View file

@ -1,60 +0,0 @@
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>;

View file

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -12,7 +16,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@ -20,11 +24,18 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": [ "include": [
"next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [ "exclude": [
"a-maze-r/node_modules" "a-maze-r/node_modules"
] ]