├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .node-version
├── .prettierignore
├── .prettierrc.json
├── index.html
├── package.json
├── postcss.config.cjs
├── public
├── favicon.svg
├── flags.png
└── sprites
│ ├── actions.svg
│ └── common.svg
├── src
├── app
│ └── index.tsx
├── features
│ ├── difficulty-selection
│ │ ├── index.ts
│ │ ├── model
│ │ │ └── index.ts
│ │ └── ui.tsx
│ └── timer
│ │ ├── index.ts
│ │ ├── lib
│ │ ├── format-time
│ │ │ └── index.ts
│ │ └── index.ts
│ │ ├── model
│ │ └── index.ts
│ │ └── ui.tsx
├── index.css
├── main.tsx
├── pages
│ ├── game
│ │ ├── index.ts
│ │ └── ui.tsx
│ ├── home
│ │ ├── index.ts
│ │ └── ui.tsx
│ └── index.tsx
├── shared
│ ├── config
│ │ ├── difficulty.ts
│ │ ├── index.ts
│ │ ├── init.ts
│ │ └── sudoku.ts
│ ├── lib
│ │ ├── atom
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── toggler
│ │ │ ├── create-toggler.ts
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ └── use-toggler.ts
│ ├── routing
│ │ └── index.ts
│ └── ui
│ │ ├── button
│ │ ├── index.ts
│ │ └── ui.tsx
│ │ ├── icon
│ │ ├── assets
│ │ │ ├── actions
│ │ │ │ ├── bulb.svg
│ │ │ │ ├── cancel.svg
│ │ │ │ ├── clear.svg
│ │ │ │ └── pen.svg
│ │ │ └── common
│ │ │ │ ├── add.svg
│ │ │ │ ├── chevron.svg
│ │ │ │ ├── moon.svg
│ │ │ │ ├── pause.svg
│ │ │ │ ├── play.svg
│ │ │ │ ├── settings.svg
│ │ │ │ ├── stats.svg
│ │ │ │ ├── sun.svg
│ │ │ │ └── time.svg
│ │ ├── index.ts
│ │ ├── sprite.h.ts
│ │ └── ui.tsx
│ │ ├── index.ts
│ │ ├── modal
│ │ ├── index.ts
│ │ ├── lib
│ │ │ ├── index.ts
│ │ │ ├── use-escape
│ │ │ │ ├── index.ts
│ │ │ │ ├── use-escape.test.ts
│ │ │ │ └── use-escape.ts
│ │ │ └── use-locked-body
│ │ │ │ └── index.ts
│ │ └── ui.tsx
│ │ ├── portal
│ │ ├── index.ts
│ │ └── ui.tsx
│ │ └── title
│ │ ├── index.ts
│ │ └── ui.tsx
├── vite-env.d.ts
└── widgets
│ ├── header
│ ├── index.ts
│ └── ui.tsx
│ └── sudoku
│ ├── index.ts
│ ├── lib
│ ├── check-mistakes
│ │ └── index.ts
│ ├── index.ts
│ ├── is-cell
│ │ ├── empty-or-mistake.ts
│ │ ├── has-mistake.ts
│ │ └── index.ts
│ ├── segment
│ │ ├── find-by-index-of-cell.ts
│ │ └── index.ts
│ └── update-board
│ │ ├── index.ts
│ │ ├── with-error-handling.ts
│ │ ├── with-key.ts
│ │ └── with-solution.ts
│ ├── model
│ ├── cell.ts
│ ├── clear.ts
│ ├── control.ts
│ ├── hint.ts
│ ├── history.ts
│ ├── hotkey.ts
│ ├── index.ts
│ ├── notes.ts
│ ├── reset.ts
│ ├── start.ts
│ └── status.ts
│ └── ui
│ ├── actions.tsx
│ ├── board
│ ├── areas
│ │ ├── index.ts
│ │ ├── lib
│ │ │ ├── calculate-area-styles
│ │ │ │ └── index.ts
│ │ │ ├── calculate-neighbours
│ │ │ │ └── index.ts
│ │ │ ├── calculate-position
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ └── ui.tsx
│ ├── cell
│ │ ├── index.ts
│ │ ├── styles.module.scss
│ │ └── ui.tsx
│ ├── index.ts
│ └── ui.tsx
│ ├── controls.tsx
│ ├── game-over.tsx
│ ├── index.tsx
│ ├── navbar
│ ├── index.ts
│ ├── list.tsx
│ └── ui.tsx
│ └── winner
│ ├── config.ts
│ ├── flags.tsx
│ ├── index.ts
│ ├── stats.tsx
│ └── ui.tsx
├── tailwind.config.cjs
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | /.git
2 | /.vscode
3 | node_modules
4 | vite.config.ts
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:react/recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:react-hooks/recommended",
11 | "plugin:import/recommended",
12 | "airbnb",
13 | "airbnb-typescript",
14 | "prettier",
15 | "plugin:react/jsx-runtime"
16 | ],
17 | "overrides": [
18 | ],
19 | "parser": "@typescript-eslint/parser",
20 | "parserOptions": {
21 | "ecmaVersion": "latest",
22 | "sourceType": "module",
23 | "project": "./tsconfig.json"
24 | },
25 | "plugins": [
26 | "react",
27 | "@typescript-eslint"
28 | ],
29 | "rules": {
30 | "import/prefer-default-export": "off",
31 | "react/function-component-definition": [
32 | 2,
33 | {
34 | "namedComponents": "arrow-function",
35 | "unnamedComponents": "arrow-function"
36 | }
37 | ]
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 16.14.0
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .eslintrc.json
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "bracketSpacing": true,
4 | "trailingComma": "es5",
5 | "useTabs": false,
6 | "semi": true,
7 | "tabWidth": 2,
8 | "endOfLine": "lf",
9 | "singleQuote": true,
10 | "overrides": [
11 | {
12 | "files": "*.{css,scss}",
13 | "options": {
14 | "singleQuote": false
15 | }
16 | }
17 | ],
18 | "importOrder": [
19 | "app/**",
20 | "pages/*/**",
21 | "features/*/**",
22 | "entities/*/**",
23 | "shared/*/*/**",
24 | "../**/app",
25 | "../**/pages",
26 | "../**/features",
27 | "../**/entities",
28 | "../**/shared"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Судоку онлайн
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sudoku-effector",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "format": "prettier ./.prettierrc.json --write ./src",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "atomic-router": "^0.8.0",
14 | "atomic-router-react": "^0.8.3",
15 | "autoprefixer": "^10.4.14",
16 | "clsx": "^1.2.1",
17 | "effector": "^22.8.6",
18 | "effector-hotkey": "^0.2.3",
19 | "effector-react": "^22.5.1",
20 | "history": "^5.3.0",
21 | "next-themes": "^0.2.1",
22 | "patronum": "^1.18.0",
23 | "react": "^18.2.0",
24 | "react-dom": "^18.2.0",
25 | "sass": "^1.68.0",
26 | "sudoku-toolbox": "^1.1.8",
27 | "tailwind-variants": "^0.1.2",
28 | "tailwindcss": "^3.3.3"
29 | },
30 | "devDependencies": {
31 | "@neodx/svg": "^0.5.1",
32 | "@tailwindcss/typography": "^0.5.9",
33 | "@testing-library/react": "^14.0.0",
34 | "@types/jest": "^29.5.1",
35 | "@types/react": "^18.0.26",
36 | "@types/react-dom": "^18.0.9",
37 | "@types/uuid": "^9.0.4",
38 | "@typescript-eslint/eslint-plugin": "^5.48.2",
39 | "@typescript-eslint/parser": "^5.48.2",
40 | "@vitejs/plugin-react": "^3.0.0",
41 | "eslint": "^8.32.0",
42 | "eslint-config-airbnb": "^19.0.4",
43 | "eslint-config-airbnb-typescript": "^17.0.0",
44 | "eslint-config-prettier": "^8.6.0",
45 | "eslint-plugin-import": "^2.27.5",
46 | "eslint-plugin-react": "^7.32.1",
47 | "eslint-plugin-react-hooks": "^4.6.0",
48 | "lint-staged": "^13.1.0",
49 | "prettier": "2.8.3",
50 | "typescript": "^4.9.3",
51 | "vite": "^4.0.0"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/flags.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Shiyan7/sudoku-effector/cd6f76891c3382533e6deb694047900d1eac6062/public/flags.png
--------------------------------------------------------------------------------
/public/sprites/actions.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/sprites/common.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { Pages } from '@/pages';
2 | import { router } from '@/shared/routing';
3 | import { RouterProvider } from 'atomic-router-react';
4 | import { ThemeProvider } from 'next-themes';
5 |
6 | export const App = () => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/src/features/difficulty-selection/index.ts:
--------------------------------------------------------------------------------
1 | export { DifficultySelection } from './ui';
2 | export { difficultyModel } from './model';
3 |
--------------------------------------------------------------------------------
/src/features/difficulty-selection/model/index.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from 'atomic-router';
2 | import { createEvent, createStore, sample } from 'effector';
3 | import { atom, createToggler } from '@/shared/lib';
4 | import { routes } from '@/shared/routing';
5 | import { DEFAULT_DIFFICULTY } from '@/shared/config';
6 | import { Difficulty } from 'sudoku-toolbox/types';
7 |
8 | export const difficultyModel = atom(() => {
9 | const difficultyToggler = createToggler();
10 |
11 | const difficultyChosen = createEvent<{ type: Difficulty }>();
12 |
13 | const $difficulty = createStore<{ type: Difficulty }>({ type: DEFAULT_DIFFICULTY });
14 |
15 | sample({
16 | source: difficultyChosen,
17 | target: [$difficulty, difficultyToggler.close],
18 | });
19 |
20 | redirect({
21 | clock: difficultyChosen,
22 | params: $difficulty,
23 | route: routes.game,
24 | });
25 |
26 | return {
27 | difficultyToggler,
28 | difficultyChosen,
29 | $difficulty,
30 | };
31 | });
32 |
--------------------------------------------------------------------------------
/src/features/difficulty-selection/ui.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { Modal } from '@/shared/ui/modal';
3 | import { useToggler } from '@/shared/lib';
4 | import { difficultyModel } from '@/features/difficulty-selection';
5 | import { difficultyItems } from '@/shared/config';
6 | import { Title } from '@/shared/ui';
7 |
8 | interface DifficultySelectionProps {
9 | description: string;
10 | isClosable?: boolean;
11 | onCancel?: () => void;
12 | onStartAgain?: () => void;
13 | }
14 |
15 | export const DifficultySelection = ({
16 | description,
17 | onCancel,
18 | onStartAgain,
19 | isClosable = true,
20 | }: DifficultySelectionProps) => {
21 | const { isOpen, close } = useToggler(difficultyModel.difficultyToggler);
22 | const { difficultyChosen } = useUnit({ difficultyChosen: difficultyModel.difficultyChosen });
23 |
24 | return (
25 |
26 |
27 |
28 | Выберите режим игры
29 |
30 |
{description}
31 |
32 | {difficultyItems.map(({ type, label }) => (
33 | -
36 | difficultyChosen({
37 | type,
38 | })
39 | }
40 | className="flex mb-1.5 bg-blue-300 rounded-md dark:bg-[#31333D] dark:hover:bg-[#3F4353] items-center justify-center cursor-pointer text-blue-100 py-[10px] px-[15px] font-medium hover:bg-[#e4eaf1] transition-colors"
41 | >
42 | {label}
43 |
44 | ))}
45 |
46 | {onStartAgain && (
47 |
53 | )}
54 |
55 | {onCancel && (
56 |
62 | )}
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/features/timer/index.ts:
--------------------------------------------------------------------------------
1 | export { Timer } from './ui';
2 | export { timerModel } from './model';
3 |
--------------------------------------------------------------------------------
/src/features/timer/lib/format-time/index.ts:
--------------------------------------------------------------------------------
1 | const padZero = (value: number) => {
2 | return value.toLocaleString('en-US', { minimumIntegerDigits: 2 });
3 | };
4 |
5 | export const formatTime = (time: number) => {
6 | const minutes = Math.floor(time / 60);
7 | const seconds = time % 60;
8 |
9 | return `${padZero(minutes)}:${padZero(seconds)}`;
10 | };
11 |
--------------------------------------------------------------------------------
/src/features/timer/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './format-time';
2 |
--------------------------------------------------------------------------------
/src/features/timer/model/index.ts:
--------------------------------------------------------------------------------
1 | import { createStore, createEvent, sample } from 'effector';
2 | import { interval } from 'patronum';
3 | import { formatTime } from '../lib';
4 | import { atom } from '@/shared/lib';
5 |
6 | export const timerModel = atom(() => {
7 | const startTimer = createEvent();
8 | const stopTimer = createEvent();
9 | const $time = createStore(0);
10 | const $formattedTime = createStore('00:00');
11 |
12 | const { tick, isRunning } = interval({
13 | timeout: 1000,
14 | start: startTimer,
15 | stop: stopTimer,
16 | });
17 |
18 | $time.on(tick, (state) => state + 1);
19 |
20 | sample({
21 | source: $time,
22 | fn: (time) => formatTime(time),
23 | target: $formattedTime,
24 | });
25 |
26 | return {
27 | startTimer,
28 | stopTimer,
29 | $time,
30 | $formattedTime,
31 | isRunning,
32 | };
33 | });
34 |
--------------------------------------------------------------------------------
/src/features/timer/ui.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { Icon } from '@/shared/ui/icon';
3 | import { timerModel } from './model';
4 |
5 | interface TimerProps {
6 | disabled?: boolean;
7 | }
8 |
9 | export const Timer = ({ disabled }: TimerProps) => {
10 | const { isRunning, startTimer, stopTimer, time } = useUnit({
11 | isRunning: timerModel.isRunning,
12 | startTimer: timerModel.startTimer,
13 | stopTimer: timerModel.stopTimer,
14 | time: timerModel.$formattedTime,
15 | });
16 |
17 | return (
18 |
19 | {time}
20 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import { App } from '@/app';
3 | import { appStarted } from '@/shared/config';
4 | import './index.css';
5 |
6 | const container = document.querySelector('#root') as HTMLElement;
7 | const root = createRoot(container);
8 |
9 | appStarted();
10 | root.render();
11 |
--------------------------------------------------------------------------------
/src/pages/game/index.ts:
--------------------------------------------------------------------------------
1 | export { GamePage } from './ui';
2 |
--------------------------------------------------------------------------------
/src/pages/game/ui.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from '@/widgets/header';
2 | import { Sudoku } from '@/widgets/sudoku';
3 |
4 | export const GamePage = () => {
5 | return (
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/pages/home/index.ts:
--------------------------------------------------------------------------------
1 | export { HomePage } from './ui';
2 |
--------------------------------------------------------------------------------
/src/pages/home/ui.tsx:
--------------------------------------------------------------------------------
1 | import { DifficultySelection, difficultyModel } from '@/features/difficulty-selection';
2 | import { useToggler } from '@/shared/lib';
3 | import { Button, Title } from '@/shared/ui';
4 |
5 | export const HomePage = () => {
6 | const { open } = useToggler(difficultyModel.difficultyToggler);
7 |
8 | return (
9 |
10 |
11 |
Киллер судоку
12 |
13 | Кроссворд из цифр
14 |
15 |
18 |
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Route } from 'atomic-router-react';
2 | import { routes } from '@/shared/routing';
3 | import { HomePage } from './home';
4 | import { GamePage } from './game';
5 |
6 | export const Pages = () => (
7 | <>
8 |
9 |
10 | >
11 | );
12 |
--------------------------------------------------------------------------------
/src/shared/config/difficulty.ts:
--------------------------------------------------------------------------------
1 | import { Difficulty } from 'sudoku-toolbox/types';
2 |
3 | type DifficultyItem = {
4 | type: Difficulty;
5 | label: string;
6 | };
7 |
8 | export const difficultyItems: DifficultyItem[] = [
9 | { type: 'easy', label: 'Лёгкий' },
10 | { type: 'medium', label: 'Средний' },
11 | { type: 'hard', label: 'Сложный' },
12 | { type: 'expert', label: 'Экспертный' },
13 | ];
14 |
--------------------------------------------------------------------------------
/src/shared/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './difficulty';
2 | export * from './init';
3 | export * from './sudoku';
4 |
--------------------------------------------------------------------------------
/src/shared/config/init.ts:
--------------------------------------------------------------------------------
1 | import { createEvent } from 'effector';
2 |
3 | export const appStarted = createEvent();
4 |
--------------------------------------------------------------------------------
/src/shared/config/sudoku.ts:
--------------------------------------------------------------------------------
1 | import { Difficulty } from 'sudoku-toolbox/types';
2 |
3 | export const EMPTY_CELL = '0';
4 | export const SEGMENT_COLS = 3;
5 | export const TABLE_COLS = SEGMENT_COLS * SEGMENT_COLS;
6 | export const SEGMENT_SIZE = SEGMENT_COLS * TABLE_COLS;
7 | export const TABLE_SIZE = TABLE_COLS * TABLE_COLS;
8 | export const DEFAULT_DIFFICULTY: Difficulty = 'easy';
9 |
--------------------------------------------------------------------------------
/src/shared/lib/atom/index.ts:
--------------------------------------------------------------------------------
1 | export const atom = (factory: () => T) => factory();
2 |
--------------------------------------------------------------------------------
/src/shared/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './toggler';
2 | export * from './atom';
3 |
--------------------------------------------------------------------------------
/src/shared/lib/toggler/create-toggler.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, createStore } from 'effector';
2 | import type { TogglerInstance } from './types';
3 |
4 | export const createToggler = (defaultValue = false): TogglerInstance => {
5 | const open = createEvent();
6 | const close = createEvent();
7 | const toggle = createEvent();
8 |
9 | const $isOpen = createStore(defaultValue)
10 | .on(open, () => true)
11 | .on(close, () => false)
12 | .on(toggle, (v) => !v);
13 |
14 | return {
15 | open,
16 | close,
17 | toggle,
18 | $isOpen,
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/src/shared/lib/toggler/index.ts:
--------------------------------------------------------------------------------
1 | export { useToggler } from './use-toggler';
2 | export { createToggler } from './create-toggler';
3 |
--------------------------------------------------------------------------------
/src/shared/lib/toggler/types.ts:
--------------------------------------------------------------------------------
1 | import type { Store, Event } from 'effector';
2 |
3 | export interface TogglerInstance {
4 | open: Event;
5 | close: Event;
6 | toggle: Event;
7 | $isOpen: Store;
8 | }
9 |
--------------------------------------------------------------------------------
/src/shared/lib/toggler/use-toggler.ts:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import type { TogglerInstance } from './types';
3 |
4 | export function useToggler(togglerInstance: TogglerInstance) {
5 | const { $isOpen, open, close, toggle } = togglerInstance;
6 |
7 | return useUnit({ isOpen: $isOpen, open, close, toggle });
8 | }
9 |
--------------------------------------------------------------------------------
/src/shared/routing/index.ts:
--------------------------------------------------------------------------------
1 | import { createHistoryRouter, createRoute } from 'atomic-router';
2 | import { createBrowserHistory } from 'history';
3 | import { appStarted } from '@/shared/config';
4 | import { Difficulty } from 'sudoku-toolbox/types';
5 | import { sample } from 'effector';
6 |
7 | export const routes = {
8 | home: createRoute(),
9 | game: createRoute<{ type: Difficulty }>(),
10 | };
11 |
12 | export const routesMap = [
13 | { path: '/', route: routes.home },
14 | { path: '/game/:type', route: routes.game },
15 | ];
16 |
17 | export const router = createHistoryRouter({
18 | routes: routesMap,
19 | });
20 |
21 | sample({
22 | clock: appStarted,
23 | fn: () => createBrowserHistory(),
24 | target: router.setHistory,
25 | });
26 |
--------------------------------------------------------------------------------
/src/shared/ui/button/index.ts:
--------------------------------------------------------------------------------
1 | export { Button } from './ui';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/button/ui.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonHTMLAttributes } from 'react';
2 | import { VariantProps, tv } from 'tailwind-variants';
3 |
4 | const base =
5 | 'inline-block h-[54px] rounded-full font-semibold px-14 text-[18px] hover:bg-[#0065c8] bg-blue-100 transition-colors';
6 |
7 | const buttonVariants = tv({
8 | base,
9 | variants: {
10 | variant: {
11 | primary: 'text-white',
12 | square: 'text-white rounded-md',
13 | },
14 | },
15 | defaultVariants: {
16 | variant: 'primary',
17 | },
18 | });
19 |
20 | interface ButtonProps extends ButtonHTMLAttributes, VariantProps {}
21 |
22 | export const Button = ({ className, variant, children, ...props }: ButtonProps) => {
23 | return (
24 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/actions/bulb.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/actions/cancel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/actions/clear.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/actions/pen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/add.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/chevron.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/moon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/pause.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/settings.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/stats.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/sun.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/assets/common/time.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/index.ts:
--------------------------------------------------------------------------------
1 | export { Icon, type IconName } from './ui';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/sprite.h.ts:
--------------------------------------------------------------------------------
1 | export interface SpritesMap {
2 | actions: 'bulb' | 'cancel' | 'clear' | 'pen';
3 | common: 'add' | 'chevron' | 'moon' | 'pause' | 'play' | 'settings' | 'stats' | 'sun' | 'time';
4 | }
5 | export const SPRITES_META = {
6 | actions: ['bulb', 'cancel', 'clear', 'pen'],
7 | common: ['add', 'chevron', 'moon', 'pause', 'play', 'settings', 'stats', 'sun', 'time'],
8 | } satisfies {
9 | actions: Array<'bulb' | 'cancel' | 'clear' | 'pen'>;
10 | common: Array<'add' | 'chevron' | 'moon' | 'pause' | 'play' | 'settings' | 'stats' | 'sun' | 'time'>;
11 | };
12 |
--------------------------------------------------------------------------------
/src/shared/ui/icon/ui.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { SVGProps } from 'react';
3 | import { SpritesMap } from './sprite.h';
4 |
5 | export type IconName = {
6 | [Key in keyof SpritesMap]: `${Key}/${SpritesMap[Key]}`;
7 | }[keyof SpritesMap];
8 |
9 | export interface IconProps extends Omit, 'name' | 'type'> {
10 | name: IconName;
11 | }
12 |
13 | export function Icon({ name, className, viewBox, ...props }: IconProps) {
14 | const [spriteName, iconName] = name.split('/');
15 |
16 | return (
17 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/shared/ui/index.ts:
--------------------------------------------------------------------------------
1 | export * from './title';
2 | export * from './button';
3 | export * from './modal';
4 | export * from './icon';
5 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/index.ts:
--------------------------------------------------------------------------------
1 | export { Modal } from './ui';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './use-escape';
2 | export * from './use-locked-body';
3 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/lib/use-escape/index.ts:
--------------------------------------------------------------------------------
1 | export { useEscape } from './use-escape';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/lib/use-escape/use-escape.test.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, renderHook } from '@testing-library/react';
2 | import { useEscape } from './use-escape';
3 |
4 | describe('useEscape', () => {
5 | test('should call callback if `Escape` is pressed', () => {
6 | const callback = jest.fn();
7 |
8 | renderHook(() => useEscape(callback));
9 |
10 | fireEvent.keyDown(document, { key: 'Escape' });
11 |
12 | expect(callback).toHaveBeenCalledTimes(1);
13 | });
14 |
15 | test('should not call callback if `Shift` is pressed', () => {
16 | const callback = jest.fn();
17 |
18 | renderHook(() => useEscape(callback));
19 |
20 | fireEvent.keyDown(document, { key: 'Shift' });
21 |
22 | expect(callback).not.toHaveBeenCalled();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/lib/use-escape/use-escape.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export function useEscape(callback?: (event: KeyboardEvent) => unknown) {
4 | useEffect(() => {
5 | function handleEscape(event: KeyboardEvent) {
6 | if (event.key === 'Escape') {
7 | callback && callback(event);
8 | }
9 | }
10 |
11 | window.document.addEventListener('keydown', handleEscape);
12 |
13 | return () => {
14 | window.document.removeEventListener('keydown', handleEscape);
15 | };
16 | }, [callback]);
17 | }
18 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/lib/use-locked-body/index.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export function useLockedBody(locked = false) {
4 | useEffect(() => {
5 | if (!locked) {
6 | return;
7 | }
8 |
9 | const originalOverflow = document.body.style.overflow;
10 |
11 | const scrollBarWidth = window.innerWidth - document.body.offsetWidth;
12 |
13 | document.body.style.paddingRight = `${scrollBarWidth}px`;
14 |
15 | // Lock body scroll
16 | document.body.style.overflow = 'hidden';
17 |
18 | return () => {
19 | document.body.style.overflow = originalOverflow;
20 |
21 | document.body.style.paddingRight = '0px';
22 | };
23 | // eslint-disable-next-line react-hooks/exhaustive-deps
24 | }, [locked]);
25 | }
26 |
--------------------------------------------------------------------------------
/src/shared/ui/modal/ui.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react';
2 | import { useEscape, useLockedBody } from './lib';
3 | import { Portal } from '@/shared/ui/portal';
4 |
5 | interface ModalProps extends PropsWithChildren {
6 | isOpen: boolean;
7 | close?: () => void;
8 | }
9 |
10 | export const Modal = ({ children, isOpen, close }: ModalProps) => {
11 | useLockedBody(isOpen);
12 |
13 | useEscape(close);
14 |
15 | return (
16 |
17 | {isOpen && (
18 |
22 |
e.stopPropagation()}
24 | className="relative z-10 mx-auto inline-flex items-center flex-col align-middle my-14 w-[85%] sm:w-auto"
25 | >
26 | {children}
27 |
28 |
29 | )}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/shared/ui/portal/index.ts:
--------------------------------------------------------------------------------
1 | export { Portal } from './ui';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/portal/ui.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
2 | import { createPortal } from 'react-dom';
3 |
4 | interface PortalProps extends PropsWithChildren {
5 | rootId?: string;
6 | }
7 |
8 | export const Portal = ({ rootId, children }: PortalProps) => {
9 | const [mounted, setMounted] = useState(false);
10 | const containerRef = useRef(null);
11 |
12 | useEffect(() => {
13 | setMounted(true);
14 | containerRef.current = document.querySelector(`${rootId}`);
15 | return () => setMounted(false);
16 | // eslint-disable-next-line react-hooks/exhaustive-deps
17 | }, []);
18 |
19 | return mounted && !!containerRef.current ? createPortal(children, containerRef.current) : null;
20 | };
21 |
--------------------------------------------------------------------------------
/src/shared/ui/title/index.ts:
--------------------------------------------------------------------------------
1 | export { Title } from './ui';
2 |
--------------------------------------------------------------------------------
/src/shared/ui/title/ui.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLAttributes, PropsWithChildren } from 'react';
2 | import { VariantProps, tv } from 'tailwind-variants';
3 |
4 | const base = 'text-blue-900 font-bold dark:text-blue-200';
5 |
6 | const titleVariants = tv({
7 | base,
8 | variants: {
9 | size: {
10 | default: 'text-[35px] leading_9 sm:text[40px] sm:leading-10',
11 | md: 'text-[21px] font-bold',
12 | sm: 'text-[18px] font-semibold',
13 | },
14 | },
15 | defaultVariants: {
16 | size: 'default',
17 | },
18 | });
19 |
20 | interface TitleProps extends PropsWithChildren>, VariantProps {
21 | as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
22 | }
23 |
24 | export const Title = ({ as = 'h1', children, size, className, ...props }: TitleProps) => {
25 | const Element = as;
26 |
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/widgets/header/index.ts:
--------------------------------------------------------------------------------
1 | export { Header } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/header/ui.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes';
2 | import { Link } from 'atomic-router-react';
3 | import { routes } from '@/shared/routing';
4 | import { Icon, IconName } from '@/shared/ui';
5 |
6 | interface Action {
7 | handler: () => void;
8 | iconName: IconName;
9 | }
10 |
11 | export const Header = () => {
12 | const { theme, setTheme } = useTheme();
13 | const isDarkTheme = theme === 'dark';
14 |
15 | const toggleTheme = () => setTheme(isDarkTheme ? 'light' : 'dark');
16 |
17 | const actions: Action[] = [
18 | { handler: toggleTheme, iconName: isDarkTheme ? 'common/moon' : 'common/sun' },
19 | { handler: () => console.log('settings'), iconName: 'common/settings' },
20 | ];
21 |
22 | return (
23 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/index.ts:
--------------------------------------------------------------------------------
1 | export { Sudoku } from './ui';
2 | export * as sudokuModel from './model';
3 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/check-mistakes/index.ts:
--------------------------------------------------------------------------------
1 | interface CheckMistakesParams {
2 | initBoard: string;
3 | board: string;
4 | solution: string;
5 | }
6 |
7 | export function checkMistakes({ initBoard, board, solution }: CheckMistakesParams): Set {
8 | const mistakes = new Set();
9 |
10 | for (let i = 0; i < board.length; i++) {
11 | const initDigit = parseInt(initBoard[i], 10);
12 | const boardDigit = parseInt(board[i], 10);
13 | const solutionDigit = parseInt(solution[i], 10);
14 |
15 | if (initDigit !== boardDigit && boardDigit !== solutionDigit) {
16 | mistakes.add(i);
17 | }
18 | }
19 |
20 | return mistakes;
21 | }
22 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './check-mistakes';
2 | export * from './segment';
3 | export * from './is-cell';
4 | export * from './update-board';
5 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/is-cell/empty-or-mistake.ts:
--------------------------------------------------------------------------------
1 | import { EMPTY_CELL } from '@/shared/config';
2 | import { isCellHasMistake } from './has-mistake';
3 |
4 | interface IsCellEmptyOrMistakeParams {
5 | board: string;
6 | indexOfCell: number;
7 | mistakes: Set;
8 | }
9 |
10 | export function isCellEmptyOrMistake({ board, indexOfCell, mistakes }: IsCellEmptyOrMistakeParams): boolean {
11 | const hasMistake = isCellHasMistake({ mistakes, indexOfCell });
12 |
13 | const cellValue = board[indexOfCell];
14 |
15 | const isEmpty = cellValue === EMPTY_CELL;
16 |
17 | return isEmpty || hasMistake;
18 | }
19 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/is-cell/has-mistake.ts:
--------------------------------------------------------------------------------
1 | interface IsCellHasMistakeParams {
2 | mistakes: Set;
3 | indexOfCell: number;
4 | }
5 |
6 | export function isCellHasMistake({ mistakes, indexOfCell }: IsCellHasMistakeParams): boolean {
7 | return mistakes.has(indexOfCell);
8 | }
9 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/is-cell/index.ts:
--------------------------------------------------------------------------------
1 | export * from './empty-or-mistake';
2 | export * from './has-mistake';
3 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/segment/find-by-index-of-cell.ts:
--------------------------------------------------------------------------------
1 | import { SEGMENT_COLS, SEGMENT_SIZE, TABLE_COLS } from '@/shared/config';
2 |
3 | export function findSegmentByIndexOfCell(indexOfCell: number): number[] {
4 | const segment: number[] = [];
5 | const segmentRow = Math.floor(indexOfCell / SEGMENT_SIZE);
6 | const segmentCol = Math.floor((indexOfCell % TABLE_COLS) / SEGMENT_COLS);
7 |
8 | for (let dy = 0; dy < SEGMENT_COLS; dy++) {
9 | for (let dx = 0; dx < SEGMENT_COLS; dx++) {
10 | const cellIndex = segmentRow * SEGMENT_SIZE + segmentCol * SEGMENT_COLS + dy * TABLE_COLS + dx;
11 |
12 | segment.push(cellIndex);
13 | }
14 | }
15 |
16 | return segment;
17 | }
18 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/segment/index.ts:
--------------------------------------------------------------------------------
1 | export * from './find-by-index-of-cell';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/update-board/index.ts:
--------------------------------------------------------------------------------
1 | export * from './with-error-handling';
2 | export * from './with-key';
3 | export * from './with-solution';
4 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/update-board/with-error-handling.ts:
--------------------------------------------------------------------------------
1 | import { isCellEmptyOrMistake } from '../is-cell';
2 |
3 | interface UpdateBoardWithErrorHandlingParams {
4 | board: string;
5 | updatedBoard: string;
6 | solution: string;
7 | key: string;
8 | indexOfCell: number;
9 | mistakes: Set;
10 | }
11 |
12 | export function updateBoardWithErrorHandling({
13 | board,
14 | solution,
15 | indexOfCell,
16 | key,
17 | updatedBoard,
18 | mistakes,
19 | }: UpdateBoardWithErrorHandlingParams): string {
20 | const solvedValue = solution[indexOfCell];
21 |
22 | const isEmptyOrMistake = isCellEmptyOrMistake({ board, indexOfCell, mistakes });
23 |
24 | if (!isEmptyOrMistake) return board;
25 |
26 | if (solvedValue !== key) throw Error();
27 |
28 | return updatedBoard;
29 | }
30 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/update-board/with-key.ts:
--------------------------------------------------------------------------------
1 | interface UpdateBoardWithKeyParams {
2 | board: string;
3 | indexOfCell: number;
4 | key: string;
5 | }
6 |
7 | export function updateBoardWithKey({ board, indexOfCell, key }: UpdateBoardWithKeyParams): string {
8 | return board.substring(0, indexOfCell) + key + board.substring(indexOfCell + 1);
9 | }
10 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/lib/update-board/with-solution.ts:
--------------------------------------------------------------------------------
1 | import { updateBoardWithKey } from './with-key';
2 |
3 | interface UpdateBoardWithSolutionParams {
4 | board: string;
5 | indexOfCell: number;
6 | solution: string;
7 | }
8 |
9 | export function updateBoardWithSolution({ board, indexOfCell, solution }: UpdateBoardWithSolutionParams): string {
10 | const solvedValue = solution[indexOfCell];
11 |
12 | return updateBoardWithKey({ board, indexOfCell, key: solvedValue });
13 | }
14 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/cell.ts:
--------------------------------------------------------------------------------
1 | import { createStore, createEvent, sample } from 'effector';
2 | import { $board, $grid, newGameStarted } from './start';
3 | import { TABLE_COLS } from '@/shared/config';
4 | import { findSegmentByIndexOfCell } from '../lib';
5 | import { timerModel } from '@/features/timer';
6 |
7 | export const $selectedCell = createStore(0);
8 | export const $selectedRow = createStore(0);
9 | export const $selectedColumn = createStore(0);
10 | export const $selectedValue = createStore(0);
11 | export const $segment = createStore([]);
12 |
13 | export const cellSelected = createEvent<{ indexOfCell: number }>();
14 |
15 | sample({
16 | clock: cellSelected,
17 | filter: timerModel.isRunning,
18 | fn: ({ indexOfCell }) => indexOfCell,
19 | target: $selectedCell,
20 | });
21 |
22 | sample({
23 | clock: [cellSelected, $selectedCell, $board],
24 | source: { grid: $grid, indexOfCell: $selectedCell },
25 | fn: ({ grid, indexOfCell }) => grid[indexOfCell],
26 | target: $selectedValue,
27 | });
28 |
29 | sample({
30 | clock: $selectedCell,
31 | fn: (indexOfCell) => Math.floor(indexOfCell / TABLE_COLS),
32 | target: $selectedRow,
33 | });
34 |
35 | sample({
36 | clock: $selectedCell,
37 | fn: (indexOfCell) => indexOfCell % TABLE_COLS,
38 | target: $selectedColumn,
39 | });
40 |
41 | sample({
42 | clock: [newGameStarted, $selectedCell],
43 | source: $selectedCell,
44 | fn: findSegmentByIndexOfCell,
45 | target: $segment,
46 | });
47 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/clear.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, sample } from 'effector';
2 | import { EMPTY_CELL } from '@/shared/config';
3 | import { $selectedCell } from './cell';
4 | import { $board } from './start';
5 | import { isCellHasMistake, updateBoardWithKey } from '../lib';
6 | import { $arrayOfNotes, cellNotesUpdated } from './notes';
7 | import { historyUpdated } from './history';
8 | import { $mistakes } from './status';
9 |
10 | export const clearClicked = createEvent();
11 |
12 | sample({
13 | clock: [clearClicked, cellNotesUpdated],
14 | filter: isCellHasMistake,
15 | source: { indexOfCell: $selectedCell, mistakes: $mistakes, board: $board },
16 | fn: ({ board, indexOfCell }) => updateBoardWithKey({ board, indexOfCell, key: EMPTY_CELL }),
17 | target: historyUpdated,
18 | });
19 |
20 | sample({
21 | clock: clearClicked,
22 | source: { indexOfCell: $selectedCell, arrayOfNotes: $arrayOfNotes },
23 | fn: ({ arrayOfNotes, indexOfCell }) => {
24 | const cellNotes = arrayOfNotes[indexOfCell];
25 |
26 | cellNotes.clear();
27 |
28 | return [...arrayOfNotes];
29 | },
30 | target: $arrayOfNotes,
31 | });
32 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/control.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, createEvent, createStore, sample } from 'effector';
2 | import { hotkey } from 'effector-hotkey';
3 | import { $selectedCell } from './cell';
4 | import { $board, $initBoard, $solution } from './start';
5 | import { $countMistakes, $mistakes } from './status';
6 | import { checkMistakes, updateBoardWithErrorHandling, updateBoardWithKey } from '../lib';
7 | import { hintClicked } from './hint';
8 | import { clearClicked } from './clear';
9 | import { timerModel } from '@/features/timer';
10 | import { not } from 'patronum';
11 | import { $isNotesEnabled, cellNotesUpdated } from './notes';
12 | import { backwardClicked, historyUpdated } from './history';
13 |
14 | export const $updatedBoard = createStore('');
15 |
16 | const $key = createStore('');
17 |
18 | const keys = Array.from({ length: 9 }, (_, v) => v + 1).join('+');
19 |
20 | export const keyPressed = hotkey({ key: keys, filter: timerModel.isRunning });
21 |
22 | export const numberPressed = createEvent<{ key: string }>();
23 |
24 | $key.on([keyPressed, numberPressed], (_, { key }) => key);
25 |
26 | const updateBoardFx = createEffect(updateBoardWithErrorHandling);
27 |
28 | sample({
29 | clock: [keyPressed, numberPressed],
30 | filter: not($isNotesEnabled),
31 | source: { board: $board, indexOfCell: $selectedCell, key: $key },
32 | fn: updateBoardWithKey,
33 | target: $updatedBoard,
34 | });
35 |
36 | sample({
37 | clock: [keyPressed, numberPressed],
38 | filter: not($isNotesEnabled),
39 | source: {
40 | board: $board,
41 | indexOfCell: $selectedCell,
42 | updatedBoard: $updatedBoard,
43 | solution: $solution,
44 | mistakes: $mistakes,
45 | key: $key,
46 | },
47 | target: updateBoardFx,
48 | });
49 |
50 | sample({
51 | clock: [keyPressed, numberPressed],
52 | filter: $isNotesEnabled,
53 | target: cellNotesUpdated,
54 | });
55 |
56 | sample({
57 | clock: [$board, hintClicked, clearClicked, backwardClicked, cellNotesUpdated],
58 | source: { initBoard: $initBoard, board: $board, solution: $solution },
59 | fn: checkMistakes,
60 | target: $mistakes,
61 | });
62 |
63 | sample({
64 | clock: updateBoardFx.failData,
65 | source: $updatedBoard,
66 | target: historyUpdated,
67 | });
68 |
69 | sample({
70 | clock: updateBoardFx.doneData,
71 | target: historyUpdated,
72 | });
73 |
74 | $countMistakes.on(updateBoardFx.failData, (state) => state + 1);
75 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/hint.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, sample } from 'effector';
2 | import { $selectedCell } from './cell';
3 | import { $board, $solution } from './start';
4 | import { isCellEmptyOrMistake, updateBoardWithSolution } from '../lib';
5 | import { historyUpdated } from './history';
6 | import { $mistakes } from './status';
7 |
8 | export const hintClicked = createEvent();
9 |
10 | sample({
11 | clock: hintClicked,
12 | filter: isCellEmptyOrMistake,
13 | source: { board: $board, indexOfCell: $selectedCell, solution: $solution, mistakes: $mistakes },
14 | fn: updateBoardWithSolution,
15 | target: historyUpdated,
16 | });
17 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/history.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, createStore } from 'effector';
2 |
3 | export const $history = createStore([]);
4 |
5 | export const historyUpdated = createEvent();
6 |
7 | $history.on(historyUpdated, (state, puzzle) => {
8 | const lastElement = state.at(-1);
9 |
10 | if (lastElement !== puzzle) {
11 | return [...state, puzzle];
12 | }
13 | });
14 |
15 | export const backwardClicked = createEvent();
16 |
17 | $history.on(backwardClicked, (state) => {
18 | if (state.length > 1) {
19 | return state.slice(0, -1);
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/hotkey.ts:
--------------------------------------------------------------------------------
1 | import { hotkey } from 'effector-hotkey';
2 | import { TABLE_COLS, TABLE_SIZE } from '@/shared/config';
3 | import { $selectedCell } from './cell';
4 | import { timerModel } from '@/features/timer';
5 |
6 | const arrowUp = hotkey({ key: 'ArrowUp', type: 'keydown', filter: timerModel.isRunning });
7 | const arrowDown = hotkey({ key: 'ArrowDown', type: 'keydown', filter: timerModel.isRunning });
8 | const arrowLeft = hotkey({ key: 'ArrowLeft', type: 'keydown', filter: timerModel.isRunning });
9 | const arrowRight = hotkey({ key: 'ArrowRight', type: 'keydown', filter: timerModel.isRunning });
10 |
11 | $selectedCell
12 | .on(arrowUp, (state) => (state >= TABLE_COLS ? state - TABLE_COLS : state))
13 | .on(arrowDown, (state) => (state + TABLE_COLS < TABLE_SIZE ? state + TABLE_COLS : state))
14 | .on(arrowLeft, (state) => (state % TABLE_COLS !== 0 ? state - 1 : state))
15 | .on(arrowRight, (state) => ((state + 1) % TABLE_COLS !== 0 ? state + 1 : state));
16 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/index.ts:
--------------------------------------------------------------------------------
1 | export * from './cell';
2 | export * from './clear';
3 | export * from './control';
4 | export * from './hint';
5 | export * from './history';
6 | export * from './hotkey';
7 | export * from './notes';
8 | export * from './start';
9 | export * from './status';
10 | export * from './reset';
11 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/notes.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, createStore, sample } from 'effector';
2 | import { $selectedCell } from './cell';
3 | import { TABLE_SIZE } from '@/shared/config';
4 |
5 | export const $isNotesEnabled = createStore(false);
6 |
7 | export const toggleNotesClicked = createEvent();
8 |
9 | $isNotesEnabled.on(toggleNotesClicked, (state) => !state);
10 |
11 | export const $arrayOfNotes = createStore>>(Array.from({ length: TABLE_SIZE }, () => new Set()));
12 |
13 | export const cellNotesUpdated = createEvent<{ key: string }>();
14 |
15 | const $newNote = createStore(0);
16 |
17 | $newNote.on(cellNotesUpdated, (_, { key }) => Number(key));
18 |
19 | sample({
20 | clock: cellNotesUpdated,
21 | source: { indexOfCell: $selectedCell, arrayOfNotes: $arrayOfNotes, newNote: $newNote },
22 | fn: ({ arrayOfNotes, indexOfCell, newNote }) => {
23 | const cellNotes = arrayOfNotes[indexOfCell];
24 |
25 | const cellHasNote = cellNotes.has(newNote);
26 |
27 | if (!cellHasNote) {
28 | return arrayOfNotes.map((notes, idx) => {
29 | const isTargetNote = idx === indexOfCell;
30 | const newNotes = new Set([...notes, newNote]);
31 |
32 | return isTargetNote ? newNotes : notes;
33 | });
34 | } else {
35 | cellNotes.delete(newNote);
36 |
37 | return [...arrayOfNotes];
38 | }
39 | },
40 | target: $arrayOfNotes,
41 | });
42 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/reset.ts:
--------------------------------------------------------------------------------
1 | import { reset } from 'patronum';
2 | import { timerModel } from '@/features/timer';
3 | import { newGameStarted } from './start';
4 | import { $countMistakes, $isLoss, $isWin, $mistakes, secondChanceClicked, startAgainClicked } from './status';
5 | import { $selectedCell } from './cell';
6 | import { $isNotesEnabled, $arrayOfNotes } from './notes';
7 | import { $history } from './history';
8 |
9 | reset({
10 | clock: newGameStarted,
11 | target: [timerModel.$time, $selectedCell, $isNotesEnabled],
12 | });
13 |
14 | reset({
15 | clock: [secondChanceClicked, startAgainClicked, newGameStarted],
16 | target: [$isLoss, $isWin],
17 | });
18 |
19 | reset({
20 | clock: [startAgainClicked, newGameStarted],
21 | target: [$countMistakes, $mistakes, $history, $arrayOfNotes],
22 | });
23 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/start.ts:
--------------------------------------------------------------------------------
1 | import { createStore, createEvent, sample } from 'effector';
2 | import { reshape } from 'patronum/reshape';
3 | import { timerModel } from '@/features/timer';
4 | import { routes } from '@/shared/routing';
5 | import { DEFAULT_DIFFICULTY } from '@/shared/config';
6 | import type { Sudoku } from 'sudoku-toolbox/types';
7 | import { generateSudoku } from 'sudoku-toolbox';
8 | import { $history, historyUpdated } from './history';
9 |
10 | const $sudoku = createStore({
11 | puzzle: '',
12 | solution: '',
13 | difficulty: DEFAULT_DIFFICULTY,
14 | areas: [],
15 | });
16 |
17 | export const $board = createStore('');
18 | export const newGameStarted = createEvent();
19 | export const $grid = createStore([]);
20 |
21 | sample({
22 | clock: [routes.game.opened, routes.game.updated],
23 | target: newGameStarted,
24 | });
25 |
26 | sample({
27 | clock: newGameStarted,
28 | target: [timerModel.stopTimer, timerModel.startTimer],
29 | });
30 |
31 | sample({
32 | clock: newGameStarted,
33 | source: routes.game.$params,
34 | fn: ({ type: difficulty }) => generateSudoku(difficulty),
35 | target: $sudoku,
36 | });
37 |
38 | sample({
39 | clock: $sudoku,
40 | fn: ({ puzzle }) => puzzle,
41 | target: historyUpdated,
42 | });
43 |
44 | sample({
45 | clock: $history,
46 | fn: (array) => array[array.length - 1],
47 | target: $board,
48 | });
49 |
50 | sample({
51 | clock: $board,
52 | fn: (board) => board.split('').map(Number),
53 | target: $grid,
54 | });
55 |
56 | export const { $initBoard, $solution, $areas } = reshape({
57 | source: $sudoku,
58 | shape: {
59 | $initBoard: (sudoku) => sudoku.puzzle,
60 | $solution: (sudoku) => sudoku.solution,
61 | $areas: (sudoku) => sudoku.areas,
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/model/status.ts:
--------------------------------------------------------------------------------
1 | import { createEvent, createStore, sample } from 'effector';
2 | import { difficultyModel } from '@/features/difficulty-selection';
3 | import { createToggler } from '@/shared/lib';
4 | import { $board, $initBoard, $solution } from './start';
5 | import { timerModel } from '@/features/timer';
6 |
7 | export const gameOverToggler = createToggler();
8 | export const $countMistakes = createStore(0);
9 |
10 | export const $mistakes = createStore>(new Set());
11 | export const $isLoss = createStore(false);
12 | export const $isWin = createStore(false);
13 |
14 | export const newGameClicked = createEvent();
15 | export const secondChanceClicked = createEvent();
16 | export const cancelClicked = createEvent();
17 | export const startAgainClicked = createEvent();
18 |
19 | $countMistakes.on(secondChanceClicked, (state) => state - 1);
20 |
21 | sample({
22 | clock: cancelClicked,
23 | target: [difficultyModel.difficultyToggler.close, gameOverToggler.open],
24 | });
25 |
26 | sample({
27 | clock: startAgainClicked,
28 | target: difficultyModel.difficultyToggler.close,
29 | });
30 |
31 | sample({
32 | clock: startAgainClicked,
33 | source: $initBoard,
34 | target: $board,
35 | });
36 |
37 | sample({
38 | clock: $countMistakes,
39 | filter: (mistakes) => mistakes >= 3,
40 | fn: () => true,
41 | target: [$isLoss, gameOverToggler.open],
42 | });
43 |
44 | sample({
45 | clock: secondChanceClicked,
46 | target: gameOverToggler.close,
47 | });
48 |
49 | sample({
50 | clock: newGameClicked,
51 | target: [gameOverToggler.close, difficultyModel.difficultyToggler.open],
52 | });
53 |
54 | sample({
55 | clock: $board,
56 | source: $solution,
57 | filter: (solution, board) => board === solution,
58 | fn: () => true,
59 | target: [$isWin, timerModel.stopTimer],
60 | });
61 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/actions.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useUnit } from 'effector-react';
3 | import type { ReactNode } from 'react';
4 | import type { IconName } from '@/shared/ui/icon';
5 | import { Icon } from '@/shared/ui';
6 | import { sudokuModel } from '@/widgets/sudoku';
7 |
8 | interface Action {
9 | label: string;
10 | chip?: ReactNode;
11 | iconName: IconName;
12 | handler: () => void;
13 | }
14 |
15 | interface ActionsProps {
16 | disabled: boolean;
17 | }
18 |
19 | export const Actions = ({ disabled }: ActionsProps) => {
20 | const { backwardClicked, isNotesEnabled, toggleNotesClicked, clearClicked, hintClicked } = useUnit({
21 | backwardClicked: sudokuModel.backwardClicked,
22 | isNotesEnabled: sudokuModel.$isNotesEnabled,
23 | toggleNotesClicked: sudokuModel.toggleNotesClicked,
24 | clearClicked: sudokuModel.clearClicked,
25 | hintClicked: sudokuModel.hintClicked,
26 | });
27 |
28 | const NotesStatus = (
29 |
35 | {isNotesEnabled ? 'On' : 'Off'}
36 |
37 | );
38 |
39 | const items: Action[] = [
40 | { label: 'Отменить', handler: backwardClicked, iconName: 'actions/cancel' },
41 | { label: 'Очистить', handler: clearClicked, iconName: 'actions/clear' },
42 | { label: 'Заметки', handler: toggleNotesClicked, iconName: 'actions/pen', chip: NotesStatus },
43 | { label: 'Подсказка', handler: hintClicked, iconName: 'actions/bulb' },
44 | ];
45 |
46 | return (
47 |
48 | {items.map(({ chip, label, handler, iconName }) => (
49 |
61 | ))}
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/index.ts:
--------------------------------------------------------------------------------
1 | export { Areas } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/lib/calculate-area-styles/index.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | type CellState = {
4 | hasCellAbove: boolean;
5 | hasCellRight: boolean;
6 | hasCellBelow: boolean;
7 | hasCellLeft: boolean;
8 | };
9 |
10 | interface CalculateAreaStylesParams {
11 | cellState: CellState;
12 | cellWidth: number;
13 | borderColor: string;
14 | }
15 |
16 | const CELL_OFFSET = 1.5;
17 | const BORDER_WIDTH = 1;
18 | const OFFSET = 6.7;
19 |
20 | function getBorderStyle(hasNeighbour: boolean, borderColor: string) {
21 | return hasNeighbour ? 'none' : `${BORDER_WIDTH}px solid ${borderColor}`;
22 | }
23 |
24 | export function calculateAreaStyles({ cellState, cellWidth, borderColor }: CalculateAreaStylesParams) {
25 | const { hasCellAbove, hasCellRight, hasCellBelow, hasCellLeft } = cellState;
26 |
27 | const CELL_SIZE = cellWidth - BORDER_WIDTH * 2 * (CELL_OFFSET * 2);
28 |
29 | const areaStyle: CSSProperties = {
30 | top: '0px',
31 | right: '0px',
32 | bottom: '0px',
33 | left: '0px',
34 | width: `${CELL_SIZE}px`,
35 | height: `${CELL_SIZE}px`,
36 | borderBottom: getBorderStyle(hasCellBelow, borderColor),
37 | borderRight: getBorderStyle(hasCellRight, borderColor),
38 | borderTop: getBorderStyle(hasCellAbove, borderColor),
39 | borderLeft: getBorderStyle(hasCellLeft, borderColor),
40 | };
41 |
42 | if (hasCellAbove) {
43 | areaStyle.top = `-${OFFSET}px`;
44 | areaStyle.height = `${CELL_SIZE + OFFSET}px`;
45 | }
46 |
47 | if (hasCellRight) {
48 | areaStyle.right = `-${OFFSET}px`;
49 | areaStyle.width = `${CELL_SIZE + OFFSET}px`;
50 | }
51 |
52 | if (hasCellBelow) {
53 | areaStyle.bottom = `-${OFFSET}px`;
54 | areaStyle.height = `${CELL_SIZE + OFFSET}px`;
55 | }
56 |
57 | if (hasCellLeft) {
58 | areaStyle.left = `-${OFFSET}px`;
59 | areaStyle.width = `${CELL_SIZE + OFFSET}px`;
60 | }
61 |
62 | if (hasCellAbove && hasCellBelow) {
63 | areaStyle.top = `-${OFFSET}px`;
64 | areaStyle.height = `${CELL_SIZE + OFFSET * 2}px`;
65 | }
66 |
67 | if (hasCellLeft && hasCellRight) {
68 | areaStyle.left = `-${OFFSET}px`;
69 | areaStyle.width = `${CELL_SIZE + OFFSET * 2}px`;
70 | }
71 |
72 | return areaStyle;
73 | }
74 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/lib/calculate-neighbours/index.ts:
--------------------------------------------------------------------------------
1 | import { Coord } from 'sudoku-toolbox/types';
2 |
3 | interface CalculateNeighboursParams {
4 | x: number;
5 | y: number;
6 | cells: Coord[];
7 | }
8 |
9 | export function calculateNeighbours({ x, y, cells }: CalculateNeighboursParams) {
10 | const hasCellAbove = cells.some(([nextX, nextY]) => nextX === x - 1 && nextY === y);
11 | const hasCellBelow = cells.some(([nextX, nextY]) => nextX === x + 1 && nextY === y);
12 | const hasCellLeft = cells.some(([nextX, nextY]) => nextX === x && nextY === y - 1);
13 | const hasCellRight = cells.some(([nextX, nextY]) => nextX === x && nextY === y + 1);
14 |
15 | return {
16 | hasCellAbove,
17 | hasCellBelow,
18 | hasCellLeft,
19 | hasCellRight,
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/lib/calculate-position/index.ts:
--------------------------------------------------------------------------------
1 | import { TABLE_COLS } from '@/shared/config';
2 |
3 | interface CalculatePositionParams {
4 | x: number;
5 | y: number;
6 | cellWidth: number;
7 | }
8 |
9 | export function calculatePosition({ x, y, cellWidth }: CalculatePositionParams) {
10 | const cellX = y * cellWidth;
11 | const cellY = x * cellWidth;
12 | const absoluteX = (cellX / TABLE_COLS) * TABLE_COLS;
13 | const absoluteY = (cellY / TABLE_COLS) * TABLE_COLS;
14 |
15 | return { left: absoluteX, top: absoluteY };
16 | }
17 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './calculate-position';
2 | export * from './calculate-area-styles';
3 | export * from './calculate-neighbours';
4 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/areas/ui.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { sudokuModel } from '@/widgets/sudoku';
3 | import { calculateAreaStyles, calculateNeighbours, calculatePosition } from './lib';
4 | import { useTheme } from 'next-themes';
5 |
6 | interface AreasProps {
7 | cellWidth: number;
8 | }
9 |
10 | export const Areas = ({ cellWidth }: AreasProps) => {
11 | const { theme } = useTheme();
12 |
13 | const { areas } = useUnit({ areas: sudokuModel.$areas });
14 |
15 | return (
16 |
17 | {areas.map(({ cells, sum }, idx) => {
18 | return (
19 |
20 | {cells.map(([x, y], idx) => {
21 | const isFirstElement = idx === 0;
22 |
23 | const { left, top } = calculatePosition({ x, y, cellWidth });
24 |
25 | const cellState = calculateNeighbours({ x, y, cells });
26 |
27 | const areaStyle = calculateAreaStyles({
28 | cellState,
29 | cellWidth,
30 | borderColor: theme === 'light' ? '#314b62' : '#5A5A62',
31 | });
32 |
33 | return (
34 |
42 |
43 | {isFirstElement && (
44 |
45 | {sum}
46 |
47 | )}
48 |
49 | );
50 | })}
51 |
52 | );
53 | })}
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/cell/index.ts:
--------------------------------------------------------------------------------
1 | export { Cell } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/cell/styles.module.scss:
--------------------------------------------------------------------------------
1 | .isNeighbourOfSelected {
2 | @apply bg-blue-800;
3 | }
4 |
5 | .isSimilar:not(.isCellSelected) {
6 | @apply bg-blue-200;
7 | }
8 |
9 | .isNewValue {
10 | @apply text-blue-100;
11 | }
12 |
13 | .isError {
14 | @apply bg-red-100 text-red-200 dark:bg-red-300 #{!important};
15 | }
16 |
17 | .isCellSelected {
18 | @apply bg-blue-700 #{!important};
19 | }
20 |
21 | .isHidden {
22 | @apply opacity-0;
23 | }
24 |
25 | .cell:not(.isNewValue) {
26 | @apply text-blue-900;
27 | }
28 |
29 | [data-theme="dark"] {
30 | .cell {
31 | &:not(.isNewValue) {
32 | @apply text-gray-100;
33 | }
34 |
35 | &.isNeighbourOfSelected {
36 | @apply bg-dark-200;
37 | }
38 |
39 | &.isCellSelected {
40 | @apply bg-blue-600 #{!important};
41 | }
42 |
43 | &.isSimilar:not(.isCellSelected) {
44 | @apply bg-black;
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/cell/ui.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import styles from './styles.module.scss';
3 |
4 | interface CellProps {
5 | value: number;
6 | isError: boolean;
7 | isCellSelected: boolean;
8 | isNeighbourOfSelected: boolean;
9 | isSimilar: boolean;
10 | isHidden: boolean;
11 | isNewValue: boolean;
12 | notesOfCell: Set;
13 | onSelect: () => void;
14 | }
15 |
16 | export const Cell = ({
17 | value,
18 | isSimilar,
19 | isCellSelected,
20 | isError,
21 | isNeighbourOfSelected,
22 | isNewValue,
23 | isHidden,
24 | notesOfCell,
25 | onSelect,
26 | }: CellProps) => {
27 | const notes = Array.from({ length: 9 }, (_, idx) => {
28 | const index = idx + 1;
29 |
30 | return [...notesOfCell].includes(index) ? index : null;
31 | });
32 |
33 | return (
34 |
49 | 0
50 |
54 | {value}
55 |
56 | {!value && (
57 |
58 | {notes.map((note, idx) => (
59 |
60 | {note}
61 |
62 | ))}
63 |
64 | )}
65 | |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/index.ts:
--------------------------------------------------------------------------------
1 | export { Board } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/board/ui.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useUnit } from 'effector-react';
3 | import { sudokuModel } from '@/widgets/sudoku';
4 | import { timerModel } from '@/features/timer';
5 | import { Icon } from '@/shared/ui/icon';
6 | import { TABLE_COLS } from '@/shared/config';
7 | import { Cell } from './cell';
8 | import { Winner } from '../winner';
9 | import { Areas } from './areas';
10 | import { useEffect, useRef, useState } from 'react';
11 |
12 | export const Board = () => {
13 | const {
14 | initBoard,
15 | arrayOfNotes,
16 | grid,
17 | selectedCell,
18 | selectedValue,
19 | segment,
20 | cellSelected,
21 | selectedRow,
22 | selectedColumn,
23 | mistakes,
24 | isRunning,
25 | startTimer,
26 | isWin,
27 | } = useUnit({
28 | initBoard: sudokuModel.$initBoard,
29 | arrayOfNotes: sudokuModel.$arrayOfNotes,
30 | grid: sudokuModel.$grid,
31 | selectedCell: sudokuModel.$selectedCell,
32 | selectedValue: sudokuModel.$selectedValue,
33 | segment: sudokuModel.$segment,
34 | cellSelected: sudokuModel.cellSelected,
35 | selectedRow: sudokuModel.$selectedRow,
36 | selectedColumn: sudokuModel.$selectedColumn,
37 | mistakes: sudokuModel.$mistakes,
38 | isRunning: timerModel.isRunning,
39 | startTimer: timerModel.startTimer,
40 | isWin: sudokuModel.$isWin,
41 | });
42 |
43 | const rows = Array.from({ length: TABLE_COLS }, (_, idx) => idx);
44 |
45 | const [cellWidth, setCellWidth] = useState(0);
46 | const containerRef = useRef(null);
47 |
48 | const handleResize = () => {
49 | const containerSize = containerRef.current?.offsetWidth;
50 | const cellWidth = (Number(containerSize) - 2) / 9;
51 | setCellWidth(cellWidth);
52 | };
53 |
54 | useEffect(() => {
55 | handleResize();
56 |
57 | window.addEventListener('resize', handleResize);
58 |
59 | return () => {
60 | window.removeEventListener('resize', handleResize);
61 | };
62 | }, []);
63 |
64 | return (
65 |
66 | {!isRunning && (
67 |
73 | )}
74 |
75 | {isRunning &&
}
76 |
82 |
83 | {rows.map((row) => (
84 |
88 | {rows.map((column) => {
89 | const indexOfCell = row * TABLE_COLS + column;
90 | const value = grid[indexOfCell];
91 | const isCellSelected = selectedCell === indexOfCell;
92 | const isRowSelected = selectedRow === row;
93 | const isColumnSelected = selectedColumn === column;
94 | const isError = [...mistakes].includes(indexOfCell);
95 | const isInSegment = segment.includes(indexOfCell);
96 | const isSimilar = !!value && value === selectedValue;
97 | const isNewValue = initBoard[indexOfCell] !== String(value);
98 | const notesOfCell = arrayOfNotes[indexOfCell];
99 |
100 | return (
101 | cellSelected({ indexOfCell })}
110 | key={indexOfCell}
111 | value={value}
112 | />
113 | );
114 | })}
115 | |
116 | ))}
117 |
118 |
119 |
120 | );
121 | };
122 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/controls.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { sudokuModel } from '@/widgets/sudoku';
3 | import clsx from 'clsx';
4 |
5 | interface ControlsProps {
6 | disabled: boolean;
7 | }
8 |
9 | export const Controls = ({ disabled }: ControlsProps) => {
10 | const { numberPressed, isNotesEnabled } = useUnit({
11 | numberPressed: sudokuModel.numberPressed,
12 | isNotesEnabled: sudokuModel.$isNotesEnabled,
13 | });
14 |
15 | const numbers = Array.from({ length: 9 }, (_, index) => String(index + 1));
16 |
17 | return (
18 |
19 | {numbers.map((key) => (
20 |
31 | ))}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/game-over.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { sudokuModel } from '@/widgets/sudoku';
3 | import { useToggler } from '@/shared/lib';
4 | import { Button, Modal, Title } from '@/shared/ui';
5 |
6 | export const GameOver = () => {
7 | const { isOpen } = useToggler(sudokuModel.gameOverToggler);
8 |
9 | const { secondChanceClicked, newGameClicked } = useUnit({
10 | secondChanceClicked: sudokuModel.secondChanceClicked,
11 | newGameClicked: sudokuModel.newGameClicked,
12 | });
13 |
14 | return (
15 |
16 |
17 |
18 | Игра окончена
19 |
20 |
21 | Вы сделали 3 ошибки и проиграли в этой игре
22 |
23 |
26 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/index.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { sudokuModel } from '@/widgets/sudoku';
3 | import { DifficultySelection, difficultyModel } from '@/features/difficulty-selection';
4 | import { Button } from '@/shared/ui/button';
5 | import { useToggler } from '@/shared/lib';
6 | import { Actions } from './actions';
7 | import { Board } from './board';
8 | import { Navbar } from './navbar';
9 | import { Controls } from './controls';
10 | import { GameOver } from './game-over';
11 | import { timerModel } from '@/features/timer';
12 |
13 | export const Sudoku = () => {
14 | const { open } = useToggler(difficultyModel.difficultyToggler);
15 |
16 | const { isRunning, cancelClicked, startAgainClicked, isLoss, isWin } = useUnit({
17 | isRunning: timerModel.isRunning,
18 | cancelClicked: sudokuModel.cancelClicked,
19 | startAgainClicked: sudokuModel.startAgainClicked,
20 | isLoss: sudokuModel.$isLoss,
21 | isWin: sudokuModel.$isWin,
22 | });
23 |
24 | const isDisabled = isWin || !isRunning;
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 |
38 |
39 |
40 | {isLoss ? (
41 |
47 | ) : (
48 |
49 | )}
50 | >
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/navbar/index.ts:
--------------------------------------------------------------------------------
1 | export { Navbar } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/navbar/list.tsx:
--------------------------------------------------------------------------------
1 | import { difficultyItems } from '@/shared/config';
2 | import { routes } from '@/shared/routing';
3 | import { Link } from 'atomic-router-react';
4 | import clsx from 'clsx';
5 | import { useUnit } from 'effector-react';
6 |
7 | interface NavbarListProps {
8 | isOpen: boolean;
9 | close: () => void;
10 | }
11 |
12 | export const NavbarList = ({ isOpen, close }: NavbarListProps) => {
13 | const { params } = useUnit({
14 | params: routes.game.$params,
15 | });
16 |
17 | return (
18 | <>
19 |
25 |
26 | {difficultyItems.map(({ type, label }) => {
27 | const isActive = type === params.type;
28 |
29 | return (
30 | -
34 |
43 | {label}
44 |
45 |
46 | );
47 | })}
48 |
49 |
50 |
57 | >
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/navbar/ui.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { Link } from 'atomic-router-react';
3 | import { useUnit } from 'effector-react';
4 | import { sudokuModel } from '@/widgets/sudoku';
5 | import { Timer } from '@/features/timer';
6 | import { routes } from '@/shared/routing';
7 | import { difficultyItems } from '@/shared/config';
8 | import { Icon } from '@/shared/ui';
9 | import { NavbarList } from './list';
10 | import { useState } from 'react';
11 |
12 | export const Navbar = () => {
13 | const [isListOpen, setIsListOpen] = useState(false);
14 |
15 | const { params, countMistakes, isWin } = useUnit({
16 | params: routes.game.$params,
17 | countMistakes: sudokuModel.$countMistakes,
18 | isWin: sudokuModel.$isWin,
19 | });
20 |
21 | const currentDifficulty = difficultyItems.find(({ type }) => type === params?.type);
22 |
23 | return (
24 |
25 |
26 |
Уровень:
27 |
28 | {difficultyItems.map(({ type, label }) => {
29 | const isActive = type === params.type;
30 |
31 | return (
32 | -
33 |
41 | {label}
42 |
43 |
44 | );
45 | })}
46 |
47 |
57 |
58 |
Ошибки: {countMistakes}/3
59 |
60 |
setIsListOpen(false)} />
61 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/winner/config.ts:
--------------------------------------------------------------------------------
1 | export const flags = [
2 | { style: 'after:bg-flag-position-1 after:bg-flag-size-1 top-0 right-0 w-[97%] h-[30%]' },
3 | { style: 'after:bg-flag-position-2 after:bg-flag-size-2 top-[-2%] right-[-10%] w-[79%] h-[26%]' },
4 | { style: 'after:bg-flag-position-3 after:bg-flag-size-3 top-[-5%] left-0 w-full h-[37%]' },
5 | ];
6 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/winner/flags.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { flags } from './config';
3 |
4 | export const Flags = () => {
5 | return (
6 |
7 | {flags.map(({ style }, idx) => (
8 |
15 | ))}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/winner/index.ts:
--------------------------------------------------------------------------------
1 | export { Winner } from './ui';
2 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/winner/stats.tsx:
--------------------------------------------------------------------------------
1 | import { useUnit } from 'effector-react';
2 | import { timerModel } from '@/features/timer';
3 | import { difficultyItems } from '@/shared/config';
4 | import { routes } from '@/shared/routing';
5 | import { Icon, IconName } from '@/shared/ui/icon';
6 |
7 | interface StatsItem {
8 | label: string;
9 | icon: IconName;
10 | value: string | number | undefined;
11 | }
12 |
13 | export const Stats = () => {
14 | const { params, formattedTime } = useUnit({
15 | params: routes.game.$params,
16 | formattedTime: timerModel.$formattedTime,
17 | });
18 |
19 | const currentDifficulty = difficultyItems.find(({ type }) => type === params?.type);
20 |
21 | const stats: StatsItem[] = [
22 | { label: 'Уровень', icon: 'common/stats', value: currentDifficulty?.label },
23 | { label: 'Время', icon: 'common/time', value: formattedTime },
24 | ];
25 |
26 | return (
27 | <>
28 | {stats.map(({ label, icon, value }) => (
29 |
33 |
34 |
35 | {label}
36 | {value}
37 |
38 |
39 | ))}
40 | >
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/widgets/sudoku/ui/winner/ui.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useUnit } from 'effector-react';
3 | import { Button } from '@/shared/ui';
4 | import { difficultyModel } from '@/features/difficulty-selection';
5 | import { sudokuModel } from '@/widgets/sudoku';
6 | import { useToggler } from '@/shared/lib';
7 | import { Flags } from './flags';
8 | import { Stats } from './stats';
9 |
10 | export const Winner = () => {
11 | const { open } = useToggler(difficultyModel.difficultyToggler);
12 | const { isWin } = useUnit({ isWin: sudokuModel.$isWin });
13 |
14 | return (
15 |
21 |
22 |
Отлично!
23 |
24 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const plugin = require('tailwindcss/plugin');
2 | const defaultTheme = require('tailwindcss/defaultTheme');
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | darkMode: ['class', '[data-theme="dark"]'],
7 | content: {
8 | files: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
9 | },
10 | theme: {
11 | colors: {
12 | 'border-color': 'hsla(0, 0%, 100%, .2)',
13 | transparent: 'transparent',
14 | white: '#fff',
15 | black: '#030305',
16 | dark: {
17 | 100: '#24242C',
18 | 200: '#16151A',
19 | 300: '#353342',
20 | 400: '#16151A',
21 | },
22 | red: {
23 | 100: '#f7cfd6',
24 | 200: '#e55c6c',
25 | 300: '#6A0307',
26 | },
27 | current: 'currentColor',
28 | gray: {
29 | 100: '#91949D',
30 | 300: '#94a3b7',
31 | 400: '#6e7c8c',
32 | },
33 | blue: {
34 | 100: '#0072e3',
35 | '100-dark': '#6B94CA',
36 | 200: '#b9c8da',
37 | 300: '#EEF2F8',
38 | 400: '#eaeef4',
39 | 500: '#dce3ed',
40 | 600: '#013E7F',
41 | 700: '#bbdefb',
42 | 800: '#e2ebf3',
43 | 900: '#314b62',
44 | },
45 | },
46 | fontFamily: {
47 | sans: ['Inter', ...defaultTheme.fontFamily.sans],
48 | },
49 | extend: {
50 | borderWidth: {
51 | 'sudoku-border': '2.5px',
52 | },
53 | zIndex: {
54 | 1000: '1000',
55 | },
56 | backgroundImage: {
57 | 'custom-gradient': 'radial-gradient(circle at 50% 0, #82ffff, #0072e3 53%)',
58 | },
59 | fontSize: {
60 | xs: '0.8rem',
61 | },
62 | },
63 | backgroundPosition: {
64 | 'flag-position-1': '.1261% 99.9641%',
65 | 'flag-position-2': '99.8961%98.761%',
66 | 'flag-position-3': '.1342% 89.0976%',
67 | },
68 | backgroundSize: {
69 | 'flag-size-1': '182.6041% 1049.3197%',
70 | 'flag-size-2': '221.8987% 1186.5384%',
71 | 'flag-size-3': '173.9087% 833.7837%',
72 | },
73 | },
74 | plugins: [
75 | require('@tailwindcss/typography'),
76 | plugin(({ addVariant }) => {
77 | addVariant('not-last', '&:not(:last-child)');
78 | }),
79 | ],
80 | };
81 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": false,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": ".",
19 | "paths": {
20 | "@/*": ["src/*"],
21 | }
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Bundler",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import svg from '@neodx/svg/vite';
3 | import react from '@vitejs/plugin-react';
4 | import path from 'path';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, './src/'),
11 | },
12 | },
13 | plugins: [
14 | react(),
15 | svg({
16 | root: 'src/shared/ui/icon/assets',
17 | group: true,
18 | metadata: 'src/shared/ui/icon/sprite.h.ts',
19 | output: 'public/sprites',
20 | }),
21 | ],
22 | server: {
23 | host: true,
24 | },
25 | });
26 |
--------------------------------------------------------------------------------