Skip to content

Commit 6f59736

Browse files
committed
add WIP sudoku page
1 parent 0cfbce6 commit 6f59736

File tree

5 files changed

+316
-2
lines changed

5 files changed

+316
-2
lines changed

package-lock.json

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"babel-plugin-prismjs": "^2.1.0",
3232
"bootstrap": "^5.3.2",
3333
"bootswatch": "^5.3.2",
34+
"classnames": "^2.3.2",
3435
"d3-color": "^3.1.0",
3536
"d3-interpolate": "^3.0.1",
3637
"date-fns": "^2.30.0",
@@ -67,7 +68,8 @@
6768
"react-markdown": "^8.0.7",
6869
"react-snowfall": "^1.2.1",
6970
"sass": "^1.69.5",
70-
"sharp": "^0.33.0"
71+
"sharp": "^0.33.0",
72+
"sudoku-gen": "^1.0.2"
7173
},
7274
"devDependencies": {
7375
"@babel/eslint-parser": "^7.23.3",

src/components/sudokuBoard.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import PropTypes from 'prop-types';
2+
import { chunk } from 'lodash-es';
3+
import { Alert, Card, Container, Row } from 'react-bootstrap';
4+
5+
import SudokuCell from 'components/sudokuCell';
6+
import { useCallback, useEffect, useMemo, useState } from 'react';
7+
8+
export default function SudokuBoard({ puzzle, solution }) {
9+
const [solved, setSolved] = useState(false);
10+
const [activeCell, setActiveCell] = useState([-1, -1]);
11+
const rows = useMemo(
12+
() =>
13+
chunk(puzzle.split(''), 9).map((row) =>
14+
row.map((value) => (value === '-' ? null : parseInt(value, 10)))
15+
),
16+
[puzzle]
17+
);
18+
const [values, setValues] = useState(Array(9).fill(Array(9).fill(-1)));
19+
const invalids = useMemo(
20+
() =>
21+
values.map((row, rowIdx) =>
22+
row.map((value, colIdx) => {
23+
if (value === -1) {
24+
return true;
25+
}
26+
27+
const targetRow = rows[rowIdx];
28+
29+
if (
30+
((targetRow &&
31+
targetRow.indexOf(value) === colIdx &&
32+
targetRow.lastIndexOf(value) === colIdx) ||
33+
(targetRow.indexOf(value) === -1 &&
34+
targetRow.lastIndexOf(value) === -1) ||
35+
row.indexOf(value) !== colIdx ||
36+
row.lastIndexOf(value) !== colIdx) &&
37+
rows.every((searchRow) => searchRow[colIdx] !== value)
38+
) {
39+
const cellRow = Math.floor(rowIdx / 3);
40+
const cellCol = Math.floor(colIdx / 3);
41+
42+
for (
43+
let searchRowIdx = cellRow * 3;
44+
searchRowIdx < (cellRow + 1) * 3;
45+
searchRowIdx++
46+
) {
47+
for (
48+
let searchColIdx = cellCol * 3;
49+
searchColIdx < (cellCol + 1) * 3;
50+
searchColIdx++
51+
) {
52+
if (
53+
searchRowIdx !== rowIdx &&
54+
searchColIdx !== colIdx &&
55+
(rows[searchRowIdx][searchColIdx] === value ||
56+
values[searchRowIdx][searchColIdx] === value)
57+
) {
58+
return false;
59+
}
60+
}
61+
}
62+
63+
return true;
64+
} else {
65+
return false;
66+
}
67+
})
68+
),
69+
[rows, values]
70+
);
71+
const handleClick = useCallback(
72+
(row, column) =>
73+
setActiveCell(([prevRow, prevCol]) => {
74+
if (prevRow === row && prevCol === column) {
75+
return [-1, -1];
76+
} else {
77+
return [row, column];
78+
}
79+
}),
80+
[]
81+
);
82+
const handleChange = useCallback((row, column, value) => {
83+
setValues((prevVal) => {
84+
const newVal = [...prevVal];
85+
const newRow = [...newVal[row]];
86+
87+
newRow[column] = value;
88+
newVal.splice(row, 1, newRow);
89+
90+
return newVal;
91+
});
92+
}, []);
93+
94+
useEffect(() => {
95+
if (!rows.length) {
96+
return;
97+
}
98+
99+
const currentBoard = rows
100+
.map((boardRow, rowIdx) =>
101+
boardRow
102+
.map((value, colIdx) => {
103+
if (value !== null) {
104+
return value.toString();
105+
}
106+
107+
const liveValue = values[rowIdx][colIdx];
108+
109+
return liveValue === -1 ? '-' : liveValue;
110+
})
111+
.join('')
112+
)
113+
.join('');
114+
115+
if (currentBoard === solution) {
116+
setSolved(true);
117+
}
118+
}, [rows, values]);
119+
120+
return (
121+
<Card body>
122+
{solved && <Alert variant="success">You did it!</Alert>}
123+
<Container fluid>
124+
{rows.map((row, rowIdx) => (
125+
<Row key={rowIdx}>
126+
{row.map((value, colIdx) => (
127+
<SudokuCell
128+
row={rowIdx}
129+
column={colIdx}
130+
key={colIdx}
131+
value={!value ? values[rowIdx][colIdx] : value}
132+
unknown={!value}
133+
active={activeCell[0] === rowIdx && activeCell[1] === colIdx}
134+
valid={invalids[rowIdx][colIdx]}
135+
onClick={handleClick}
136+
onChange={handleChange}
137+
/>
138+
))}
139+
</Row>
140+
))}
141+
</Container>
142+
</Card>
143+
);
144+
}
145+
146+
SudokuBoard.propTypes = {
147+
puzzle: PropTypes.string.isRequired,
148+
solution: PropTypes.string.isRequired
149+
};

src/components/sudokuCell.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import PropTypes from 'prop-types';
2+
import classNames from 'classnames';
3+
import { Col, Form } from 'react-bootstrap';
4+
import { useCallback, useEffect, useRef } from 'react';
5+
6+
export default function SudokuCell({
7+
row,
8+
column,
9+
value,
10+
unknown = false,
11+
active = false,
12+
valid = true,
13+
onClick,
14+
onChange
15+
}) {
16+
const textRef = useRef(null);
17+
const handleChange = useCallback(
18+
(event) => {
19+
const newVal = parseInt(event.target.value, 10);
20+
21+
if (isNaN(newVal) || newVal < 1 || newVal > 9) {
22+
return;
23+
}
24+
25+
onChange(row, column, newVal);
26+
onClick(-1, -1);
27+
},
28+
[onClick, onChange]
29+
);
30+
const handleKeyDown = useCallback(
31+
({ key }) => {
32+
if (!active || !['Enter', 'Tab'].includes(key)) {
33+
return;
34+
}
35+
36+
event.preventDefault();
37+
onClick(-1, -1);
38+
},
39+
[active, onClick]
40+
);
41+
const handleBlur = useCallback(() => onClick(-1, -1), [onClick]);
42+
43+
useEffect(() => {
44+
if (active && textRef.current) {
45+
textRef.current.focus();
46+
}
47+
}, [active]);
48+
49+
const displayValue = value === -1 ? '' : value;
50+
const text = unknown ? displayValue : <strong>{displayValue}</strong>;
51+
52+
return (
53+
<Col
54+
onClick={() => {
55+
if (onClick) {
56+
onClick(row, column);
57+
}
58+
}}
59+
className={classNames(
60+
'd-flex',
61+
'justify-content-center',
62+
'align-items-center',
63+
'text-dark',
64+
'p-0',
65+
'border-2',
66+
'border-dark',
67+
column > 0 && column % 3 === 2 && 'border-end',
68+
row > 0 && row % 3 === 2 && 'border-bottom',
69+
valid ? 'bg-white' : 'bg-danger'
70+
)}
71+
style={{ height: 48, maxWidth: 48, minWidth: 48 }}
72+
>
73+
{unknown && active ? (
74+
<Form.Control
75+
ref={textRef}
76+
type="text"
77+
className={classNames('bg-warning', 'p-1', 'text-center', 'h-100')}
78+
onChange={handleChange}
79+
onKeyDown={handleKeyDown}
80+
onBlur={handleBlur}
81+
/>
82+
) : (
83+
text
84+
)}
85+
</Col>
86+
);
87+
}
88+
89+
SudokuCell.propTypes = {
90+
row: PropTypes.number.isRequired,
91+
column: PropTypes.number.isRequired,
92+
value: PropTypes.number,
93+
unknown: PropTypes.bool,
94+
active: PropTypes.bool,
95+
valid: PropTypes.bool,
96+
onClick: PropTypes.func.isRequired,
97+
onChange: PropTypes.func.isRequired
98+
};

src/pages/sudoku.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useState } from 'react';
2+
import { Container, Row, Col, Button, ButtonGroup } from 'react-bootstrap';
3+
import { getSudoku } from 'sudoku-gen';
4+
5+
import Layout from 'components/layout';
6+
import SEO from 'components/seo';
7+
import SudokuBoard from 'components/sudokuBoard';
8+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
9+
import {
10+
faCheckCircle,
11+
faFloppyDisk,
12+
faFolderOpen,
13+
faQuestionCircle,
14+
faRecycle
15+
} from '@fortawesome/free-solid-svg-icons';
16+
17+
export default function SudokuPage() {
18+
const [puzzle, setPuzzle] = useState(getSudoku('easy'));
19+
20+
return (
21+
<Layout>
22+
<SEO title="Sudoku" />
23+
<Container>
24+
<Row>
25+
<Col xs={12}>
26+
<h1>Sudoku</h1>
27+
</Col>
28+
</Row>
29+
<Row className="mb-2">
30+
<Col xs={12}>
31+
<ButtonGroup>
32+
<Button onClick={() => setPuzzle(getSudoku('easy'))}>
33+
<FontAwesomeIcon icon={faRecycle} /> New Board
34+
</Button>
35+
<Button disabled>
36+
<FontAwesomeIcon icon={faFloppyDisk} /> Save
37+
</Button>
38+
<Button disabled>
39+
<FontAwesomeIcon icon={faFolderOpen} /> Load
40+
</Button>
41+
<Button disabled>
42+
<FontAwesomeIcon icon={faQuestionCircle} /> Get Hint
43+
</Button>
44+
<Button variant="success">
45+
<FontAwesomeIcon icon={faCheckCircle} /> Check Answer
46+
</Button>
47+
</ButtonGroup>
48+
</Col>
49+
</Row>
50+
<Row>
51+
<Col className="d-flex justify-content-center">
52+
<SudokuBoard {...puzzle} />
53+
</Col>
54+
</Row>
55+
</Container>
56+
</Layout>
57+
);
58+
}

0 commit comments

Comments
 (0)