Refactor
All checks were successful
/ maven-build (push) Successful in 20s

This commit is contained in:
Manuel Friedli 2026-03-07 23:47:38 +01:00
parent 824f038084
commit 3efb87bcd8
Signed by: manuel
GPG key ID: 41D08ABA75634DA1
24 changed files with 14 additions and 19 deletions

12
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
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%
);
}

85
app/cell.tsx Normal file
View file

@ -0,0 +1,85 @@
import {MazeCell} from "./model/maze.ts";
import Coordinates from "./model/coordinates.ts";
import {Action, actionClickedCell} from "./state/action.ts";
import styles from "./cell.module.scss";
import "./cell.scss";
import {State} from "./state/state.ts";
import {ActionDispatch} from "react";
function getMarkedDirections(coords: Coordinates, marked: Coordinates[]): Direction[] {
const cellIndex: number = marked.findIndex(e => e.x === coords.x && e.y === coords.y);
if (cellIndex === -1) {
return [];
}
if (marked.length === 1) {
return [Direction.SELF];
}
if (cellIndex === 0) {
const next: Coordinates = marked[1];
return [Direction.SELF, getDirectionTo(coords, next)];
} else {
const previous = marked[cellIndex - 1];
if (cellIndex === marked.length - 1) {
return [Direction.SELF, getDirectionTo(coords, previous)];
}
const next: Coordinates = marked[cellIndex + 1];
return [Direction.SELF, getDirectionTo(coords, previous), getDirectionTo(coords, next)];
}
}
function getDirectionTo(me: Coordinates, other: Coordinates): Direction {
const xDiff = me.x - other.x;
switch (xDiff) {
case -1:
return Direction.RIGHT;
case 1:
return Direction.LEFT;
default:
const yDiff = me.y - other.y;
if (yDiff === -1) {
return Direction.DOWN;
}
return Direction.UP;
}
}
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 markedDirections = getMarkedDirections({x, y}, state.userPath);
for (let i = 0; i < markedDirections.length; i++) {
classes += ` user${Direction[markedDirections[i]]}`;
}
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 className={"marker UP"}></div>
<div className={"marker RIGHT"}></div>
<div className={"marker DOWN"}></div>
<div className={"marker LEFT"}></div>
</div>
);
}
enum Direction {
UP, RIGHT, DOWN, LEFT, SELF
}

56
app/globals.scss Normal file
View file

@ -0,0 +1,56 @@
: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-solution: #47e147;
--color-accent: #ffcc00;
--color-accent-inverse: #000000;
}
@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-solution: #00a421;
}
}
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);
}

View file

@ -0,0 +1,15 @@
.inputform {
display: grid;
grid-row-gap: 0.5em;
grid-template-columns: 7em 14em auto;
}
.submitbutton {
background-color: var(--color-accent);
border: 1px solid var(--color-foreground);
color: var(--color-accent-inverse);
padding: 0.5em;
border-radius: 0.5em;
margin-top: 2em;
width: 11em;
}

21
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);
}

119
app/input-form.tsx Normal file
View file

@ -0,0 +1,119 @@
import {ActionDispatch, FormEvent, useState} from 'react';
import {
Action,
actionLoadedMaze,
actionLoadingFailed,
actionStartedLoading,
actionToggledShowSolution
} from "./state/action.ts";
import styles from "./input-form.module.scss";
import "./input-form.scss";
import {State} from "./state/state.ts";
import {ValidatingInputNumberField, ValidatingInputRegExpField, ValidatorFunction} from "./validating-input-field.tsx";
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<string>();
const [algorithm, setAlgorithm] = useState('wilson');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
dispatch(actionStartedLoading());
dispatch(actionToggledShowSolution(false));
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 => {
dispatch(actionLoadedMaze(result));
})
.catch(reason => {
console.error("Failed to fetch maze data.", reason);
dispatch(actionLoadingFailed(reason));
});
};
const validateSizeInput: ValidatorFunction<string, number> = (value: string) => {
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
};
};
return (
<form onSubmit={handleSubmit}>
<div className={styles.inputform}>
<ValidatingInputNumberField id={"width"}
label={"Width"}
value={width}
constraints={{
min: 2
}}
validatorFn={validateSizeInput}
disabled={state.loading}
onChange={setWidth}
/>
<ValidatingInputNumberField id={"height"}
label={"Height"}
value={height}
constraints={{
min: 2
}}
validatorFn={validateSizeInput}
disabled={state.loading}
onChange={setHeight}
/>
{/*<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)"}
value={id}
disabled={state.loading}
onChange={setId}
constraints={[
/^[0-9a-fA-F]{0,16}$/
]}
placeholder={"Hex-Number (without 0x prefix)"}
/>
<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>
);
}

18
app/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import type {Metadata} from "next";
import "./globals.scss";
export const metadata: Metadata = {
title: "A-Maze-R! Create your own Maze!",
description: "A Maze Generator by fritteli",
icons: "/favicon.ico"
};
export default function RootLayout({children}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

11
app/maze.module.css Normal file
View 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
app/maze.tsx Normal file
View file

@ -0,0 +1,29 @@
import Cell from "./cell.tsx";
import styles from "./maze.module.css";
import {State} from "./state/state.ts";
import {ActionDispatch, JSX} from "react";
import {Action} from "./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>
);
}

View file

@ -0,0 +1,10 @@
.banner {
height: 2em;
}
.empty {
}
.message {
border: 1px solid red;
background-color: #ff000044;
}

24
app/message-banner.tsx Normal file
View file

@ -0,0 +1,24 @@
import {Action, actionClosedMessageBanner} from "./state/action.ts";
import styles from "./message-banner.module.css";
import {State} from "./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>);
}

4
app/model/coordinates.ts Normal file
View file

@ -0,0 +1,4 @@
export default interface Coordinates {
x: number,
y: number
}

19
app/model/maze.ts Normal file
View file

@ -0,0 +1,19 @@
import Coordinates from "./coordinates.ts";
export default interface Maze {
id: string,
width: number,
height: number,
start: Coordinates,
end: Coordinates,
algorithm: string,
grid: MazeCell[][]
}
export interface MazeCell {
top: boolean,
right: boolean,
bottom: boolean,
left: boolean,
solution: boolean
}

22
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;
}
}

45
app/page.tsx Normal file
View file

@ -0,0 +1,45 @@
'use client'
import styles from "./page.module.scss";
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}, Algorithm: {state.maze!.algorithm},
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"}
checked={state.showSolution}
onChange={(e) => {
dispatch(actionToggledShowSolution(e.target.checked));
}}
id={"showSolution"}/><label htmlFor="showSolution">Show Solution</label>
<Maze state={state}
dispatch={dispatch}/>
</>
}
</div>
);
}

60
app/state/action.ts Normal file
View file

@ -0,0 +1,60 @@
import Maze from "../model/maze.ts";
export interface Action {
type: string,
[key: string]: boolean | number | string | object | null | undefined;
}
export const ID_ACTION_STARTED_LOADING = 'started_loading';
export function actionStartedLoading(): Action {
return {
type: ID_ACTION_STARTED_LOADING
}
}
export const ID_ACTION_LOADED_MAZE = 'loaded_maze';
export function actionLoadedMaze(maze: Maze): Action {
return {
type: ID_ACTION_LOADED_MAZE,
maze
}
}
export const ID_ACTION_LOADING_FAILED = 'loading_failed';
export function actionLoadingFailed(reason: string): Action {
return {
type: ID_ACTION_LOADING_FAILED,
reason
};
}
export const ID_ACTION_TOGGLED_SHOW_SOLUTION = 'toggled_show_solution';
export function actionToggledShowSolution(value: boolean): Action {
return {
type: ID_ACTION_TOGGLED_SHOW_SOLUTION,
value
}
}
export const ID_ACTION_CLOSED_MESSAGE_BANNER = 'closed_message_banner';
export function actionClosedMessageBanner(): Action {
return {
type: ID_ACTION_CLOSED_MESSAGE_BANNER
}
}
export const ID_ACTION_CLICKED_CELL = 'clicked_cell';
export function actionClickedCell(x: number, y: number): Action {
return {
type: ID_ACTION_CLICKED_CELL,
x,
y
}
}

59
app/state/reducer.ts Normal file
View file

@ -0,0 +1,59 @@
import handleUserClicked from "./userpathhandler.ts";
import {State} from "./state";
import {
Action,
ID_ACTION_CLICKED_CELL,
ID_ACTION_CLOSED_MESSAGE_BANNER,
ID_ACTION_LOADED_MAZE,
ID_ACTION_LOADING_FAILED,
ID_ACTION_STARTED_LOADING,
ID_ACTION_TOGGLED_SHOW_SOLUTION
} from "./action.ts";
import Maze from "../model/maze.ts";
export default function reduce(state: State, action: Action): State {
switch (action.type) {
case ID_ACTION_STARTED_LOADING: {
return {
...state,
maze: null,
loading: true,
errorMessage: null
}
}
case ID_ACTION_LOADED_MAZE: {
return {
...state,
loading: false,
maze: action.maze as Maze,
userPath: []
}
}
case ID_ACTION_LOADING_FAILED: {
return {
...state,
loading: false,
errorMessage: `Failed to load maze. Reason: ${action.reason}`
}
}
case ID_ACTION_TOGGLED_SHOW_SOLUTION: {
return {
...state,
showSolution: action.value as boolean
}
}
case ID_ACTION_CLOSED_MESSAGE_BANNER: {
return {
...state,
errorMessage: null
}
}
case ID_ACTION_CLICKED_CELL: {
// There's so much logic involved, externalize that into its own file.
return handleUserClicked(state, action.x as number, action.y as number);
}
default: {
throw new Error(`Unknown action: ${action.type}`);
}
}
}

18
app/state/state.ts Normal file
View file

@ -0,0 +1,18 @@
import Coordinates from "../model/coordinates.ts";
import Maze from "../model/maze.ts";
export interface State {
errorMessage: string | null,
loading: boolean,
maze: Maze | null,
showSolution: boolean,
userPath: Coordinates[]
}
export const INITIAL_STATE: State = {
errorMessage: null,
loading: false,
maze: null,
showSolution: false,
userPath: []
};

View file

@ -0,0 +1,82 @@
import {State} from "./state";
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 === maze.end.x && y === maze.end.y;
return {
...state,
userPath: [...state.userPath, {x, y}],
errorMessage: showMessage ? "Congratulations! You won!" : state.errorMessage
};
} else {
// The clicked cell IS part of the userpath. Is it the last cell of it?
const lastCoordsFromUserPath = getLastCoordsFromUserPath(state)!;
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
// Yes, it's the last cell of the userpath --> remove it.
return {
...state,
userPath: state.userPath.filter(step => step.x !== x || step.y !== y),
errorMessage: null
}
}
}
}
// Not allowed to toggle that cell. Don't apply any change to the state.
return state;
}
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;
}
if (lastCoordsFromUserPath.x === x && lastCoordsFromUserPath.y === y) {
// toggling the last position in the path is always allowed
return true;
}
if (Math.abs(x - lastCoordsFromUserPath.x) + Math.abs(y - lastCoordsFromUserPath.y) !== 1) {
// It's not a neighbor. So it's not allowed.
return false;
}
const lastCell = getCellAt(lastCoordsFromUserPath, state);
if (x === lastCoordsFromUserPath.x + 1) {
// There must be no wall to the right of the last cell.
return !lastCell.right;
}
if (x === lastCoordsFromUserPath.x - 1) {
// There must be no wall to the left of the last cell.
return !lastCell.left;
}
if (y === lastCoordsFromUserPath.y + 1) {
// There must be no wall below the last cell.
return !lastCell.bottom;
}
if (y === lastCoordsFromUserPath.y - 1) {
// There must be no wall above the last cell.
return !lastCell.top;
}
return false;
}
function getCellAt(coords: Coordinates, state: State): MazeCell {
return state.maze!.grid[coords.y][coords.x];
}
function getLastCoordsFromUserPath(state: State): Coordinates | null {
if (state.userPath.length > 0) {
return state.userPath[state.userPath.length - 1];
}
return null;
}

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>;