├── .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 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /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 | 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 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/actions/cancel.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/actions/clear.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/actions/pen.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/chevron.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/stats.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/shared/ui/icon/assets/common/time.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /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 | 18 | 19 | 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 |
24 | 28 | 29 | Назад 30 | 31 | 32 | Киллер судоку 33 | 34 |
35 | {actions.map(({ handler, iconName }) => ( 36 | 39 | ))} 40 |
41 |
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 | --------------------------------------------------------------------------------