├── src ├── hocs │ ├── Readme.md │ └── with-audio-player │ │ ├── with-audio-player.tsx │ │ └── with-audio-player.test.tsx ├── types │ ├── auth-data.ts │ ├── user-data.ts │ ├── state.ts │ └── question.ts ├── setupTests.ts ├── browser-history.ts ├── pages │ ├── loading-screen │ │ ├── loading-screen.tsx │ │ └── loading-screen.test.tsx │ ├── error-screen │ │ ├── error-screen.tsx │ │ └── error-screen.test.tsx │ ├── not-found-screen │ │ ├── not-found-screen.test.tsx │ │ └── not-found-screen.tsx │ ├── game-over-screen │ │ ├── game-over-screen.tsx │ │ └── game-over-screen.test.tsx │ ├── welcome-screen │ │ └── welcome-screen.tsx │ ├── genre-question-screen │ │ └── genre-question-screen.tsx │ ├── auth-screen │ │ ├── auth-screen.test.tsx │ │ └── auth-screen.tsx │ ├── win-screen │ │ └── win-screen.tsx │ ├── game-screen │ │ └── game-screen.tsx │ └── artist-question-screen │ │ └── artist-question-screen.tsx ├── store │ ├── action.ts │ ├── game-process │ │ ├── selectors.ts │ │ ├── game-process.selectors.test.ts │ │ ├── game-process.ts │ │ └── game-process.test.ts │ ├── user-process │ │ ├── selectors.ts │ │ ├── user-process.ts │ │ ├── user-process.selctors.test.ts │ │ └── user.process.test.ts │ ├── root-reducer.ts │ ├── index.ts │ ├── game-data │ │ ├── selectors.ts │ │ ├── game-data.ts │ │ ├── game-data.selectors.test.ts │ │ └── game-data.test.ts │ ├── middlewares │ │ ├── redirect.ts │ │ └── redirect.test.ts │ ├── api-actions.ts │ └── api-actions.test.ts ├── hooks │ ├── index.ts │ ├── use-element-listener.ts │ ├── use-user-answers.ts │ └── use-user-answer.test.ts ├── components │ ├── logo │ │ ├── logo.tsx │ │ └── logo.test.tsx │ ├── mistakes │ │ ├── mistakes.tsx │ │ └── mistakes.test.tsx │ ├── private-route │ │ ├── private-route.tsx │ │ └── private-route.test.tsx │ ├── history-route │ │ └── history-route.tsx │ ├── genre-question-item │ │ ├── genre-question-item.tsx │ │ └── genre-question-item.test.tsx │ ├── audio-player │ │ ├── audio-player.test.tsx │ │ └── audio-player.tsx │ ├── genre-question-list │ │ └── genre-question-list.tsx │ └── app │ │ ├── app.tsx │ │ └── app.test.tsx ├── services │ ├── token.ts │ └── api.ts ├── const.ts ├── index.tsx ├── game.ts ├── utils │ ├── mocks.ts │ └── mock-component.tsx └── game.test.ts ├── markup ├── favicon.ico ├── img │ ├── pickup.png │ ├── sprite.png │ ├── vinyl.png │ ├── icon-cc.png │ ├── melody-logo.png │ ├── placeholder.jpg │ ├── icon-note-active.png │ ├── icon-note-wrong.png │ ├── icon-note-correct.png │ ├── icon-note-inactive.png │ ├── melody-logo-ginger.png │ ├── player-background.png │ ├── right-arrow.svg │ ├── icon-cross.svg │ └── logo-htmla.svg ├── fonts │ ├── FiraSans-Bold.woff2 │ ├── FiraSans-Medium.woff2 │ ├── FiraSans-Regular.woff2 │ └── FiraSans-Regular-latin.woff2 ├── index.html ├── fail-tries.html ├── welcome.html ├── result-success.html ├── main.html ├── login.html ├── question-artist.html ├── question-genre.html └── css │ ├── main.min.css │ └── main.css ├── public ├── img │ ├── pickup.png │ ├── sprite.png │ ├── vinyl.png │ ├── icon-cc.png │ ├── melody-logo.png │ ├── placeholder.jpg │ ├── icon-note-active.png │ ├── icon-note-wrong.png │ ├── icon-note-correct.png │ ├── icon-note-inactive.png │ ├── melody-logo-ginger.png │ ├── player-background.png │ ├── right-arrow.svg │ ├── icon-cross.svg │ └── logo-htmla.svg ├── fonts │ ├── FiraSans-Bold.woff2 │ ├── FiraSans-Medium.woff2 │ ├── FiraSans-Regular.woff2 │ └── FiraSans-Regular-latin.woff2 └── css │ ├── main.min.css │ └── main.css ├── .gitattributes ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── .editorconfig ├── .github └── workflows │ └── check.yml ├── .eslintrc.cjs ├── tsconfig.json ├── index.html ├── package.json └── Readme.md /src/hocs/Readme.md: -------------------------------------------------------------------------------- 1 | # hocs 2 | 3 | В данной директории можно размещать high order component. 4 | -------------------------------------------------------------------------------- /markup/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/favicon.ico -------------------------------------------------------------------------------- /src/types/auth-data.ts: -------------------------------------------------------------------------------- 1 | export type AuthData = { 2 | login: string; 3 | password: string; 4 | }; 5 | -------------------------------------------------------------------------------- /markup/img/pickup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/pickup.png -------------------------------------------------------------------------------- /markup/img/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/sprite.png -------------------------------------------------------------------------------- /markup/img/vinyl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/vinyl.png -------------------------------------------------------------------------------- /public/img/pickup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/pickup.png -------------------------------------------------------------------------------- /public/img/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/sprite.png -------------------------------------------------------------------------------- /public/img/vinyl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/vinyl.png -------------------------------------------------------------------------------- /markup/img/icon-cc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/icon-cc.png -------------------------------------------------------------------------------- /public/img/icon-cc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/icon-cc.png -------------------------------------------------------------------------------- /markup/img/melody-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/melody-logo.png -------------------------------------------------------------------------------- /markup/img/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/placeholder.jpg -------------------------------------------------------------------------------- /public/img/melody-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/melody-logo.png -------------------------------------------------------------------------------- /public/img/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/placeholder.jpg -------------------------------------------------------------------------------- /src/types/user-data.ts: -------------------------------------------------------------------------------- 1 | export type UserData = { 2 | id: number; 3 | email: string; 4 | token: string; 5 | }; 6 | -------------------------------------------------------------------------------- /markup/img/icon-note-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/icon-note-active.png -------------------------------------------------------------------------------- /markup/img/icon-note-wrong.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/icon-note-wrong.png -------------------------------------------------------------------------------- /public/img/icon-note-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/icon-note-active.png -------------------------------------------------------------------------------- /public/img/icon-note-wrong.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/icon-note-wrong.png -------------------------------------------------------------------------------- /markup/fonts/FiraSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/fonts/FiraSans-Bold.woff2 -------------------------------------------------------------------------------- /markup/fonts/FiraSans-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/fonts/FiraSans-Medium.woff2 -------------------------------------------------------------------------------- /markup/img/icon-note-correct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/icon-note-correct.png -------------------------------------------------------------------------------- /markup/img/icon-note-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/icon-note-inactive.png -------------------------------------------------------------------------------- /markup/img/melody-logo-ginger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/melody-logo-ginger.png -------------------------------------------------------------------------------- /markup/img/player-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/img/player-background.png -------------------------------------------------------------------------------- /public/fonts/FiraSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/fonts/FiraSans-Bold.woff2 -------------------------------------------------------------------------------- /public/fonts/FiraSans-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/fonts/FiraSans-Medium.woff2 -------------------------------------------------------------------------------- /public/img/icon-note-correct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/icon-note-correct.png -------------------------------------------------------------------------------- /public/img/icon-note-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/icon-note-inactive.png -------------------------------------------------------------------------------- /public/img/melody-logo-ginger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/melody-logo-ginger.png -------------------------------------------------------------------------------- /public/img/player-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/img/player-background.png -------------------------------------------------------------------------------- /markup/fonts/FiraSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/fonts/FiraSans-Regular.woff2 -------------------------------------------------------------------------------- /public/fonts/FiraSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/fonts/FiraSans-Regular.woff2 -------------------------------------------------------------------------------- /markup/fonts/FiraSans-Regular-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/markup/fonts/FiraSans-Regular-latin.woff2 -------------------------------------------------------------------------------- /public/fonts/FiraSans-Regular-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/guess-melody-demo/HEAD/public/fonts/FiraSans-Regular-latin.woff2 -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import matchers from '@testing-library/jest-dom/matchers'; 2 | import { expect } from 'vitest'; 3 | 4 | expect.extend(matchers); 5 | -------------------------------------------------------------------------------- /src/browser-history.ts: -------------------------------------------------------------------------------- 1 | import {createBrowserHistory} from 'history'; 2 | 3 | const browserHistory = createBrowserHistory(); 4 | 5 | export default browserHistory; 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.webp binary 7 | *.woff binary 8 | *.woff2 binary 9 | *.ttf binary 10 | -------------------------------------------------------------------------------- /src/pages/loading-screen/loading-screen.tsx: -------------------------------------------------------------------------------- 1 | function LoadingScreen(): JSX.Element { 2 | return ( 3 |

Loading ...

4 | ); 5 | } 6 | 7 | export default LoadingScreen; 8 | -------------------------------------------------------------------------------- /src/store/action.ts: -------------------------------------------------------------------------------- 1 | import {createAction} from '@reduxjs/toolkit'; 2 | import {AppRoute} from '../const'; 3 | 4 | export const redirectToRoute = createAction('game/redirectToRoute'); 5 | -------------------------------------------------------------------------------- /markup/img/right-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/right-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'; 2 | import type {State, AppDispatch} from '../types/state'; 3 | 4 | export const useAppDispatch = () => useDispatch(); 5 | 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /src/store/game-process/selectors.ts: -------------------------------------------------------------------------------- 1 | import {NameSpace} from '../../const'; 2 | import {State} from '../../types/state'; 3 | 4 | export const getStep = (state: Pick): number => state[NameSpace.Game].step; 5 | export const getMistakeCount = (state: Pick): number => state[NameSpace.Game].mistakes; 6 | -------------------------------------------------------------------------------- /markup/img/icon-cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/icon-cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/logo/logo.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from 'react-router-dom'; 2 | 3 | function Logo(): JSX.Element { 4 | return ( 5 | 6 | Сыграть ещё раз 7 | Угадай мелодию 8 | 9 | ); 10 | } 11 | 12 | export default Logo; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | Thumbs.db 16 | .DS_Store 17 | .env 18 | *.log* 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # editors 25 | .idea 26 | *.sublime* 27 | .vscode 28 | -------------------------------------------------------------------------------- /src/pages/loading-screen/loading-screen.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import LoadingScreen from './loading-screen'; 3 | 4 | describe('Component: Loading screen', () => { 5 | it('should render correct', () => { 6 | const expectedText = /Loading/i; 7 | 8 | render(); 9 | 10 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from 'vite'; 5 | import react from '@vitejs/plugin-react'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react() 11 | ], 12 | test: { 13 | globals: true, 14 | environment: 'jsdom', 15 | setupFiles: [ 16 | './src/setupTests.ts' 17 | ] 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/store/user-process/selectors.ts: -------------------------------------------------------------------------------- 1 | import {NameSpace} from '../../const'; 2 | import {State} from '../../types/state'; 3 | import {AuthorizationStatus} from '../../const'; 4 | 5 | export const getAuthorizationStatus = (state: Pick): AuthorizationStatus => state[NameSpace.User].authorizationStatus; 6 | export const getAuthCheckedStatus = (state: Pick): boolean => state[NameSpace.User].authorizationStatus !== AuthorizationStatus.Unknown; 7 | -------------------------------------------------------------------------------- /src/services/token.ts: -------------------------------------------------------------------------------- 1 | const AUTH_TOKEN_KEY_NAME = 'guess-melody-token'; 2 | 3 | export type Token = string; 4 | 5 | export const getToken = (): Token => { 6 | const token = localStorage.getItem(AUTH_TOKEN_KEY_NAME); 7 | return token ?? ''; 8 | }; 9 | 10 | export const saveToken = (token: Token): void => { 11 | localStorage.setItem(AUTH_TOKEN_KEY_NAME, token); 12 | }; 13 | 14 | export const dropToken = (): void => { 15 | localStorage.removeItem(AUTH_TOKEN_KEY_NAME); 16 | }; 17 | -------------------------------------------------------------------------------- /src/store/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import {combineReducers} from '@reduxjs/toolkit'; 2 | import {NameSpace} from '../const'; 3 | import {gameData} from './game-data/game-data'; 4 | import {gameProcess} from './game-process/game-process'; 5 | import {userProcess} from './user-process/user-process'; 6 | 7 | export const rootReducer = combineReducers({ 8 | [NameSpace.Data]: gameData.reducer, 9 | [NameSpace.Game]: gameProcess.reducer, 10 | [NameSpace.User]: userProcess.reducer, 11 | }); 12 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import {configureStore} from '@reduxjs/toolkit'; 2 | import {rootReducer} from './root-reducer'; 3 | import {createAPI} from '../services/api'; 4 | import {redirect} from './middlewares/redirect'; 5 | 6 | export const api = createAPI(); 7 | 8 | export const store = configureStore({ 9 | reducer: rootReducer, 10 | middleware: (getDefaultMiddleware) => 11 | getDefaultMiddleware({ 12 | thunk: { 13 | extraArgument: api, 14 | }, 15 | }).concat(redirect), 16 | }); 17 | -------------------------------------------------------------------------------- /src/store/game-data/selectors.ts: -------------------------------------------------------------------------------- 1 | import {NameSpace} from '../../const'; 2 | import {State} from '../../types/state'; 3 | import {Questions} from '../../types/question'; 4 | 5 | export const getQuestions = (state: Pick): Questions => state[NameSpace.Data].questions; 6 | export const getQuestionsDataLoadingStatus = (state: Pick): boolean => state[NameSpace.Data].isQuestionsDataLoading; 7 | export const getErrorStatus = (state: Pick): boolean => state[NameSpace.Data].hasError; 8 | -------------------------------------------------------------------------------- /src/components/mistakes/mistakes.tsx: -------------------------------------------------------------------------------- 1 | type MistakesProps = { 2 | count: number; 3 | }; 4 | 5 | function Mistakes({count}: MistakesProps): JSX.Element { 6 | const mistakes = Array.from({length: count}, () => ''); 7 | 8 | return ( 9 |
10 | {mistakes.map((_item, index) => { 11 | const keyValue = `mistake-${index}`; 12 | return
; 13 | })} 14 |
15 | ); 16 | } 17 | 18 | export default Mistakes; 19 | -------------------------------------------------------------------------------- /src/components/private-route/private-route.tsx: -------------------------------------------------------------------------------- 1 | import {Navigate} from 'react-router-dom'; 2 | import {AppRoute, AuthorizationStatus} from '../../const'; 3 | 4 | type PrivateRouteProps = { 5 | authorizationStatus: AuthorizationStatus; 6 | children: JSX.Element; 7 | } 8 | 9 | function PrivateRoute(props: PrivateRouteProps): JSX.Element { 10 | const {authorizationStatus, children} = props; 11 | 12 | return ( 13 | authorizationStatus === AuthorizationStatus.Auth 14 | ? children 15 | : 16 | ); 17 | } 18 | 19 | export default PrivateRoute; 20 | -------------------------------------------------------------------------------- /src/store/middlewares/redirect.ts: -------------------------------------------------------------------------------- 1 | import {PayloadAction} from '@reduxjs/toolkit'; 2 | import browserHistory from '../../browser-history'; 3 | import {Middleware} from 'redux'; 4 | import {rootReducer} from '../root-reducer'; 5 | 6 | type Reducer = ReturnType; 7 | 8 | export const redirect: Middleware = 9 | () => 10 | (next) => 11 | (action: PayloadAction) => { 12 | if (action.type === 'game/redirectToRoute') { 13 | browserHistory.push(action.payload); 14 | } 15 | 16 | return next(action); 17 | }; 18 | -------------------------------------------------------------------------------- /src/types/state.ts: -------------------------------------------------------------------------------- 1 | import {store} from '../store/index.js'; 2 | import {Questions} from './question.js'; 3 | import {AuthorizationStatus} from '../const.js'; 4 | 5 | export type GameData = { 6 | questions: Questions; 7 | isQuestionsDataLoading: boolean; 8 | hasError: boolean; 9 | }; 10 | 11 | export type GameProcess = { 12 | mistakes: number; 13 | step: number; 14 | }; 15 | 16 | export type UserProcess = { 17 | authorizationStatus: AuthorizationStatus; 18 | }; 19 | 20 | export type State = ReturnType; 21 | 22 | export type AppDispatch = typeof store.dispatch; 23 | -------------------------------------------------------------------------------- /src/components/logo/logo.test.tsx: -------------------------------------------------------------------------------- 1 | import Logo from './logo'; 2 | import { withHistory } from '../../utils/mock-component'; 3 | import { render, screen } from '@testing-library/react'; 4 | 5 | describe('Component: Logo', () => { 6 | it('should render correctly', () => { 7 | const expectedText = 'Сыграть ещё раз'; 8 | const expectedAltText = 'Угадай мелодию'; 9 | const preparedComponent = withHistory(); 10 | 11 | render(preparedComponent); 12 | 13 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 14 | expect(screen.getByAltText(expectedAltText)).toBeInTheDocument(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/hooks/use-element-listener.ts: -------------------------------------------------------------------------------- 1 | import {RefObject, useEffect} from 'react'; 2 | 3 | export const useElementListener = ( 4 | eventName: K, 5 | element: RefObject, 6 | listener: (evt: HTMLElementEventMap[K]) => void 7 | ) => { 8 | 9 | useEffect(() => { 10 | const domElement = element.current; 11 | 12 | if (!domElement) { 13 | return; 14 | } 15 | 16 | domElement.addEventListener(eventName, listener); 17 | 18 | return () => { 19 | domElement.removeEventListener(eventName, listener); 20 | }; 21 | }, [eventName, element, listener]); 22 | }; 23 | -------------------------------------------------------------------------------- /src/pages/error-screen/error-screen.tsx: -------------------------------------------------------------------------------- 1 | import {useAppDispatch} from '../../hooks'; 2 | import {fetchQuestionAction} from '../../store/api-actions'; 3 | 4 | function ErrorScreen(): JSX.Element { 5 | const dispatch = useAppDispatch(); 6 | 7 | return ( 8 | <> 9 |

Не удалось загрузить вопросы

10 | 19 | 20 | ); 21 | } 22 | 23 | export default ErrorScreen; 24 | -------------------------------------------------------------------------------- /src/pages/not-found-screen/not-found-screen.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import NotFoundScreen from './not-found-screen'; 3 | import { withHistory } from '../../utils/mock-component'; 4 | 5 | describe('Component: NotFoundScreen', () => { 6 | it('should render correctly', () => { 7 | const expectedHeaderText = '404. Page not found'; 8 | const expectedLinkText = 'Вернуться на главную'; 9 | 10 | render(withHistory()); 11 | 12 | expect(screen.getByText(expectedHeaderText)).toBeInTheDocument(); 13 | expect(screen.getByText(expectedLinkText)).toBeInTheDocument(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Файл с настройками для редактора. 2 | # 3 | # Если вы разрабатываете в редакторе от JetBrains, BBEdit, Coda или SourceLair 4 | # этот файл уже поддерживается и не нужно производить никаких дополнительных 5 | # действий. 6 | # 7 | # Если вы ведёте разработку в другом редакторе, зайдите 8 | # на http://editorconfig.org и в разделе «Download a Plugin» 9 | # скачайте дополнение для вашего редактора. 10 | 11 | root = true 12 | 13 | [*] 14 | charset = utf-8 15 | end_of_line = lf 16 | indent_size = 2 17 | indent_style = space 18 | insert_final_newline = true 19 | trim_trailing_whitespace = true 20 | quote_type = single 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const FIRST_GAME_STEP = 0; 2 | export const MAX_MISTAKE_COUNT = 3; 3 | 4 | export enum AppRoute { 5 | Login = '/login', 6 | Lose = '/lose', 7 | Result = '/result', 8 | Root = '/', 9 | Game = '/game' 10 | } 11 | 12 | export enum AuthorizationStatus { 13 | Auth = 'AUTH', 14 | NoAuth = 'NO_AUTH', 15 | Unknown = 'UNKNOWN', 16 | } 17 | 18 | export enum GameType { 19 | Artist = 'artist', 20 | Genre = 'genre', 21 | } 22 | 23 | export enum APIRoute { 24 | Questions = '/questions', 25 | Login = '/login', 26 | Logout = '/logout', 27 | } 28 | 29 | export enum NameSpace { 30 | Data = 'DATA', 31 | Game = 'GAME', 32 | User = 'USER', 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/use-user-answers.ts: -------------------------------------------------------------------------------- 1 | import {useState} from 'react'; 2 | import {QuestionGenre} from '../types/question'; 3 | 4 | type ResultUserAnswers = [boolean[], (id: number, value: boolean) => void]; 5 | 6 | export const useUserAnswers = (question: QuestionGenre): ResultUserAnswers => { 7 | const answersCount = question.answers.length; 8 | 9 | const [answers, setAnswers] = useState(Array.from({length: answersCount}, () => false)); 10 | 11 | const handleAnswerChange = (id: number, value: boolean) => { 12 | const userAnswers = answers.slice(0); 13 | userAnswers[id] = value; 14 | setAnswers(userAnswers); 15 | }; 16 | 17 | return [answers, handleAnswerChange]; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/mistakes/mistakes.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import Mistakes from './mistakes'; 3 | 4 | describe('Component: Mistakes', () => { 5 | it('should render correct', () => { 6 | const expectedCount = 3; 7 | const mistakeContainerTestId = 'mistake-container'; 8 | const mistakeValueTestId = 'mistake-value'; 9 | 10 | render(); 11 | const mistakesContainer = screen.getByTestId(mistakeContainerTestId); 12 | const mistakeValues = screen.getAllByTestId(mistakeValueTestId); 13 | 14 | expect(mistakesContainer).toBeInTheDocument(); 15 | expect(mistakeValues.length).toBe(expectedCount); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/store/game-process/game-process.selectors.test.ts: -------------------------------------------------------------------------------- 1 | import { NameSpace } from '../../const'; 2 | import { getMistakeCount, getStep } from './selectors'; 3 | 4 | describe('GameProcess selectors', () => { 5 | const state = { 6 | [NameSpace.Game]: { 7 | mistakes: 3, 8 | step: 4, 9 | } 10 | }; 11 | 12 | it('should return mistakes count from state', () => { 13 | const { mistakes } = state[NameSpace.Game]; 14 | const result = getMistakeCount(state); 15 | expect(result).toBe(mistakes); 16 | }); 17 | 18 | it('should return step number from state', () => { 19 | const { step } = state[NameSpace.Game]; 20 | const result = getStep(state); 21 | expect(result).toBe(step); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: '*' 7 | 8 | name: Project check 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: '18' 18 | 19 | - uses: actions/checkout@master 20 | name: Checkout 21 | 22 | - name: Install dependencies 23 | run: | 24 | if [ -f 'package.json' ]; then npm install; else echo 'Skip. The package.json file does not exist'; fi 25 | 26 | - name: Run checks 27 | run: | 28 | if [ -f 'package.json' ]; then npm test && npm run lint; else echo 'Skip. The package.json file does not exist'; fi 29 | -------------------------------------------------------------------------------- /src/types/question.ts: -------------------------------------------------------------------------------- 1 | export type ArtistAnswer = { 2 | artist: string; 3 | picture: string; 4 | }; 5 | 6 | export type Song = { 7 | artist: string; 8 | src: string; 9 | }; 10 | 11 | export type QuestionArtist = { 12 | answers: ArtistAnswer[]; 13 | song: Song; 14 | type: 'artist'; 15 | }; 16 | 17 | export type GenreAnswer = { 18 | src: string; 19 | genre: string; 20 | }; 21 | 22 | export type QuestionGenre = { 23 | answers: GenreAnswer[]; 24 | genre: string; 25 | type: 'genre'; 26 | }; 27 | 28 | export type Question = QuestionArtist | QuestionGenre; 29 | 30 | export type Questions = Question[]; 31 | 32 | export type UserGenreQuestionAnswer = readonly boolean[]; 33 | 34 | export type UserArtistQuestionAnswer = string; 35 | 36 | export type UserAnswer = UserArtistQuestionAnswer | UserGenreQuestionAnswer; 37 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | env: { 5 | browser: true, 6 | es2022: true 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:react-hooks/recommended', 12 | 'htmlacademy/react-typescript' 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'module', 18 | project: 'tsconfig.json' 19 | }, 20 | settings: { 21 | react: { 22 | version: 'detect' 23 | } 24 | }, 25 | plugins: [ 26 | 'react-refresh' 27 | ], 28 | rules: { 29 | 'react-refresh/only-export-components': 'warn' 30 | }, 31 | overrides: [ 32 | { 33 | files: [ 34 | '*test*' 35 | ], 36 | rules: { 37 | '@typescript-eslint/unbound-method': 'off' 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/components/history-route/history-route.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useLayoutEffect} from 'react'; 2 | import {Router} from 'react-router-dom'; 3 | import type {BrowserHistory} from 'history'; 4 | 5 | export interface HistoryRouterProps { 6 | history: BrowserHistory; 7 | basename?: string; 8 | children?: React.ReactNode; 9 | } 10 | 11 | function HistoryRouter({ 12 | basename, 13 | children, 14 | history, 15 | }: HistoryRouterProps) { 16 | const [state, setState] = useState({ 17 | action: history.action, 18 | location: history.location, 19 | }); 20 | 21 | useLayoutEffect(() => history.listen(setState), [history]); 22 | 23 | return ( 24 | 30 | {children} 31 | 32 | ); 33 | } 34 | 35 | export default HistoryRouter; 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ESNext" 8 | ], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | "types": [ 12 | "vitest/globals", 13 | "@testing-library/jest-dom" 14 | ], 15 | 16 | /* Bundler mode */ 17 | "moduleResolution": "bundler", 18 | "allowImportingTsExtensions": true, 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "forceConsistentCasingInFileNames": true, 30 | }, 31 | "include": [ 32 | "src", 33 | "vite.config.ts" 34 | ], 35 | "references": [{ 36 | "path": "./tsconfig.node.json" 37 | }] 38 | } 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import {Provider} from 'react-redux'; 4 | import App from './components/app/app'; 5 | import {ToastContainer} from 'react-toastify'; 6 | import {store} from './store'; 7 | import {fetchQuestionAction, checkAuthAction} from './store/api-actions'; 8 | import 'react-toastify/dist/ReactToastify.css'; 9 | import HistoryRouter from './components/history-route/history-route'; 10 | import browserHistory from './browser-history'; 11 | 12 | store.dispatch(fetchQuestionAction()); 13 | store.dispatch(checkAuthAction()); 14 | 15 | const root = ReactDOM.createRoot( 16 | document.getElementById('root') as HTMLElement 17 | ); 18 | 19 | root.render( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | , 28 | ); 29 | -------------------------------------------------------------------------------- /src/game.ts: -------------------------------------------------------------------------------- 1 | import {Question, QuestionArtist, QuestionGenre, UserArtistQuestionAnswer, UserGenreQuestionAnswer, UserAnswer} from './types/question'; 2 | import {GameType} from './const'; 3 | 4 | export const isArtistAnswerCorrect = (question: QuestionArtist, userAnswer: UserArtistQuestionAnswer): boolean => 5 | userAnswer === question.song.artist; 6 | 7 | export const isGenreAnswerCorrect = (question: QuestionGenre, userAnswer: UserGenreQuestionAnswer): boolean => 8 | userAnswer.every((answer, index) => 9 | answer === (question.answers[index].genre === question.genre)); 10 | 11 | export const isAnswerCorrect = (question: Question, answer: UserAnswer): boolean => { 12 | if (question.type === GameType.Artist && typeof answer === 'string') { 13 | return isArtistAnswerCorrect(question, answer); 14 | } 15 | 16 | if (question.type === GameType.Genre && Array.isArray(answer)) { 17 | return isGenreAnswerCorrect(question, answer); 18 | } 19 | 20 | return false; 21 | }; 22 | -------------------------------------------------------------------------------- /src/pages/not-found-screen/not-found-screen.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from 'react-router-dom'; 2 | import {Helmet} from 'react-helmet-async'; 3 | import Logo from '../../components/logo/logo'; 4 | 5 | function NotFoundScreen(): JSX.Element { 6 | return ( 7 |
8 | 9 | Угадай мелодию. Страница не найдена 10 | 11 |
12 | 13 | 14 | 15 | 18 | 19 |
20 | 21 |
22 |

404. Page not found

23 | Вернуться на главную 24 |
25 |
26 | ); 27 | } 28 | 29 | export default NotFoundScreen; 30 | -------------------------------------------------------------------------------- /src/store/game-data/game-data.ts: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit'; 2 | import {NameSpace} from '../../const'; 3 | import {GameData} from '../../types/state'; 4 | import {fetchQuestionAction} from '../api-actions'; 5 | 6 | const initialState: GameData = { 7 | questions: [], 8 | isQuestionsDataLoading: false, 9 | hasError: false, 10 | }; 11 | 12 | export const gameData = createSlice({ 13 | name: NameSpace.Data, 14 | initialState, 15 | reducers: {}, 16 | extraReducers(builder) { 17 | builder 18 | .addCase(fetchQuestionAction.pending, (state) => { 19 | state.isQuestionsDataLoading = true; 20 | state.hasError = false; 21 | }) 22 | .addCase(fetchQuestionAction.fulfilled, (state, action) => { 23 | state.questions = action.payload; 24 | state.isQuestionsDataLoading = false; 25 | }) 26 | .addCase(fetchQuestionAction.rejected, (state) => { 27 | state.isQuestionsDataLoading = false; 28 | state.hasError = true; 29 | }); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/hocs/with-audio-player/with-audio-player.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentType} from 'react'; 2 | import {useState} from 'react'; 3 | import AudioPlayer from '../../components/audio-player/audio-player'; 4 | 5 | type HOCProps = { 6 | renderPlayer: (src: string, id: number) => void; 7 | }; 8 | 9 | function withAudioPlayer(Component: ComponentType) 10 | : ComponentType> { 11 | 12 | type ComponentProps = Omit; 13 | 14 | function WithAudioPlayer(props: ComponentProps): JSX.Element { 15 | const [activePlayerId, setActivePlayerId] = useState(0); 16 | return ( 17 | ( 20 | { 24 | setActivePlayerId(activePlayerId === id ? -1 : id); 25 | }} 26 | /> 27 | )} 28 | /> 29 | ); 30 | } 31 | 32 | return WithAudioPlayer; 33 | } 34 | 35 | export default withAudioPlayer; 36 | -------------------------------------------------------------------------------- /src/store/game-process/game-process.ts: -------------------------------------------------------------------------------- 1 | import {createSlice, PayloadAction} from '@reduxjs/toolkit'; 2 | import {isAnswerCorrect} from '../../game'; 3 | import {NameSpace, FIRST_GAME_STEP} from '../../const'; 4 | import {GameProcess} from '../../types/state'; 5 | import {Question, UserAnswer} from '../../types/question'; 6 | 7 | const initialState: GameProcess = { 8 | mistakes: 0, 9 | step: FIRST_GAME_STEP, 10 | }; 11 | 12 | const STEP_COUNT = 1; 13 | 14 | export const gameProcess = createSlice({ 15 | name: NameSpace.Game, 16 | initialState, 17 | reducers: { 18 | incrementStep: (state) => { 19 | state.step = state.step + STEP_COUNT; 20 | }, 21 | checkUserAnswer: (state, action: PayloadAction<{question: Question; userAnswer: UserAnswer}>) => { 22 | const {question, userAnswer} = action.payload; 23 | 24 | state.mistakes += Number(!isAnswerCorrect(question, userAnswer)); 25 | }, 26 | resetGame: (state) => { 27 | state.mistakes = 0; 28 | state.step = FIRST_GAME_STEP; 29 | }, 30 | }, 31 | }); 32 | 33 | export const {incrementStep, checkUserAnswer, resetGame} = gameProcess.actions; 34 | -------------------------------------------------------------------------------- /src/hocs/with-audio-player/with-audio-player.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, screen} from '@testing-library/react'; 2 | import withAudioPlayer from './with-audio-player'; 3 | import { internet } from 'faker'; 4 | 5 | describe('HOC: withAudioPlayer', () => { 6 | it('should render correctly with HOC', () => { 7 | const expectedText = 'wrappedComponent'; 8 | const mockComponent = () => {expectedText}; 9 | const PreparedComponent = withAudioPlayer(mockComponent); 10 | 11 | render(); 12 | 13 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 14 | }); 15 | 16 | it('should render audio player via render prop', () => { 17 | type MockComponentProps = { 18 | renderPlayer: (src: string, playerIndex: number) => JSX.Element; 19 | }; 20 | const MockComponent = ({renderPlayer}: MockComponentProps) => ( 21 | <>{renderPlayer(internet.url(), 0)} 22 | ); 23 | 24 | const PreparedComponent = withAudioPlayer(MockComponent); 25 | 26 | render( 27 | 28 | ); 29 | 30 | expect(screen.getByTestId('audio')).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/store/game-data/game-data.selectors.test.ts: -------------------------------------------------------------------------------- 1 | import { NameSpace } from '../../const'; 2 | import { makeFakeArtistQuestion } from '../../utils/mocks'; 3 | import { getErrorStatus, getQuestions, getQuestionsDataLoadingStatus } from './selectors'; 4 | 5 | describe('GameData selectors', () => { 6 | const mockArtistQuestion = makeFakeArtistQuestion(); 7 | const state = { 8 | [NameSpace.Data]: { 9 | questions: [mockArtistQuestion], 10 | isQuestionsDataLoading: true, 11 | hasError: false, 12 | } 13 | }; 14 | 15 | it('should return questions from state', () => { 16 | const { questions } = state[NameSpace.Data]; 17 | const result = getQuestions(state); 18 | expect(result).toEqual(questions); 19 | }); 20 | 21 | it('should return questions data loading status', () => { 22 | const { isQuestionsDataLoading } = state[NameSpace.Data]; 23 | const result = getQuestionsDataLoadingStatus(state); 24 | expect(result).toBe(isQuestionsDataLoading); 25 | }); 26 | 27 | it('should return error status from state', () => { 28 | const { hasError } = state[NameSpace.Data]; 29 | const result = getErrorStatus(state); 30 | expect(result).toBe(hasError); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/store/user-process/user-process.ts: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit'; 2 | import {NameSpace, AuthorizationStatus} from '../../const'; 3 | import {UserProcess} from '../../types/state'; 4 | import {checkAuthAction, loginAction, logoutAction} from '../api-actions'; 5 | 6 | const initialState: UserProcess = { 7 | authorizationStatus: AuthorizationStatus.Unknown, 8 | }; 9 | 10 | export const userProcess = createSlice({ 11 | name: NameSpace.User, 12 | initialState, 13 | reducers: {}, 14 | extraReducers(builder) { 15 | builder 16 | .addCase(checkAuthAction.fulfilled, (state) => { 17 | state.authorizationStatus = AuthorizationStatus.Auth; 18 | }) 19 | .addCase(checkAuthAction.rejected, (state) => { 20 | state.authorizationStatus = AuthorizationStatus.NoAuth; 21 | }) 22 | .addCase(loginAction.fulfilled, (state) => { 23 | state.authorizationStatus = AuthorizationStatus.Auth; 24 | }) 25 | .addCase(loginAction.rejected, (state) => { 26 | state.authorizationStatus = AuthorizationStatus.NoAuth; 27 | }) 28 | .addCase(logoutAction.fulfilled, (state) => { 29 | state.authorizationStatus = AuthorizationStatus.NoAuth; 30 | }); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/game-over-screen/game-over-screen.tsx: -------------------------------------------------------------------------------- 1 | import {Helmet} from 'react-helmet-async'; 2 | import {useNavigate} from 'react-router-dom'; 3 | import {useAppDispatch} from '../../hooks'; 4 | import {resetGame} from '../../store/game-process/game-process'; 5 | import {AppRoute} from '../../const'; 6 | 7 | function GameOverScreen(): JSX.Element { 8 | const dispatch = useAppDispatch(); 9 | const navigate = useNavigate(); 10 | 11 | return ( 12 |
13 | 14 | Угадай мелодию. Какая жалость! 15 | 16 |
17 | Угадай мелодию 18 |
19 |

Какая жалость!

20 |

У вас закончились все попытки. Ничего, повезёт в следующий раз!

21 | 31 |
32 | ); 33 | } 34 | 35 | export default GameOverScreen; 36 | -------------------------------------------------------------------------------- /src/store/user-process/user-process.selctors.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationStatus, NameSpace } from '../../const'; 2 | import { UserProcess } from '../../types/state'; 3 | import { getAuthCheckedStatus, getAuthorizationStatus } from './selectors'; 4 | 5 | describe('UserProcess selectors', () => { 6 | it('should return authorization status from state', () => { 7 | const authorizationStatus = AuthorizationStatus.Auth; 8 | const state: UserProcess = { authorizationStatus }; 9 | 10 | const result = getAuthorizationStatus({ [NameSpace.User]: state }); 11 | 12 | expect(result).toBe(authorizationStatus); 13 | }); 14 | 15 | it('should return "true" because auth status is "Auth"', () => { 16 | const authorizationStatus = AuthorizationStatus.Auth; 17 | const state: UserProcess = { authorizationStatus }; 18 | 19 | const result = getAuthCheckedStatus({ [NameSpace.User]: state }); 20 | 21 | expect(result).toBe(true); 22 | }); 23 | 24 | it('should return "false" because auth status is "Unknown"', () => { 25 | const authorizationStatus = AuthorizationStatus.Unknown; 26 | const state: UserProcess = { authorizationStatus }; 27 | 28 | const result = getAuthCheckedStatus({ [NameSpace.User]: state }); 29 | 30 | expect(result).toBe(false); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/genre-question-item/genre-question-item.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent} from 'react'; 2 | import {GenreAnswer} from '../../types/question'; 3 | 4 | type GenreQuestionItemProps = { 5 | answer: GenreAnswer; 6 | id: number; 7 | onChange: (id: number, value: boolean) => void; 8 | renderPlayer: (path: string, playerIndex: number) => JSX.Element; 9 | userAnswer: boolean; 10 | } 11 | 12 | function GenreQuestionItem(props: GenreQuestionItemProps): JSX.Element { 13 | const {answer, id, onChange, renderPlayer, userAnswer} = props; 14 | 15 | return ( 16 |
17 | {renderPlayer(answer.src, id)} 18 |
19 | ) => { 27 | const value = target.checked; 28 | onChange(id, value); 29 | }} 30 | /> 31 | 37 |
38 |
39 | ); 40 | } 41 | 42 | export default GenreQuestionItem; 43 | -------------------------------------------------------------------------------- /src/store/middlewares/redirect.test.ts: -------------------------------------------------------------------------------- 1 | import { MockStore, configureMockStore } from '@jedmao/redux-mock-store'; 2 | import { redirect } from './redirect'; 3 | import browserHistory from '../../browser-history'; 4 | import { AnyAction } from '@reduxjs/toolkit'; 5 | import { redirectToRoute } from '../action'; 6 | import { AppRoute } from '../../const'; 7 | 8 | vi.mock('../../browser-history', () => ({ 9 | default: { 10 | location: { pathname: ''}, 11 | push(path: string) { 12 | this.location.pathname = path; 13 | } 14 | } 15 | })); 16 | 17 | describe('Redirect middleware', () => { 18 | let store: MockStore; 19 | 20 | beforeAll(() => { 21 | const middleware = [redirect]; 22 | const mockStoreCreator = configureMockStore(middleware); 23 | store = mockStoreCreator(); 24 | }); 25 | 26 | beforeEach(() => { 27 | browserHistory.push(''); 28 | }); 29 | 30 | it('should redirect to "/login" with redirectToRoute action', () => { 31 | const redirectAction = redirectToRoute(AppRoute.Login); 32 | store.dispatch(redirectAction); 33 | expect(browserHistory.location.pathname).toBe(AppRoute.Login); 34 | }); 35 | 36 | it('should not redirect to "/lose" with empty action', () => { 37 | const emptyAction = { type: '', payload: AppRoute.Lose }; 38 | store.dispatch(emptyAction); 39 | expect(browserHistory.location.pathname).not.toBe(AppRoute.Lose); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/hooks/use-user-answer.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import { useUserAnswers } from './use-user-answers'; 3 | import { makeFakeGenreQuestion } from '../utils/mocks'; 4 | 5 | describe('Hook: useUserAnswers', () => { 6 | it('should return array with 2 elements', () => { 7 | const fakeQuestionGenre = makeFakeGenreQuestion(); 8 | 9 | const { result } = renderHook(() => useUserAnswers(fakeQuestionGenre)); 10 | const [answers, handleAnswerChange] = result.current; 11 | 12 | expect(result.current).toHaveLength(2); 13 | expect(answers).toBeInstanceOf(Array); 14 | expect(typeof handleAnswerChange).toBe('function'); 15 | }); 16 | 17 | it('should be correctly change state', () => { 18 | const fakeQuestionGenre = makeFakeGenreQuestion(); 19 | const expectedInitialAnswers = [false, false, false, false]; 20 | const expectedAnswers = [false, true, false, true]; 21 | 22 | const {result} = renderHook(() => useUserAnswers(fakeQuestionGenre)); 23 | const [initialAnswers] = result.current; 24 | let [, handleAnswerChange] = result.current; 25 | 26 | act(() => handleAnswerChange(1, true)); 27 | [, handleAnswerChange] = result.current; 28 | act(() => handleAnswerChange(3, true)); 29 | const [answers] = result.current; 30 | 31 | expect(initialAnswers).toEqual(expectedInitialAnswers); 32 | expect(answers).toEqual(expectedAnswers); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/pages/error-screen/error-screen.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import ErrorScreen from './error-screen'; 4 | import { withStore } from '../../utils/mock-component'; 5 | import { fetchQuestionAction } from '../../store/api-actions'; 6 | import { extractActionsTypes } from '../../utils/mocks'; 7 | import { APIRoute } from '../../const'; 8 | 9 | describe('Component: ErrorScreen', () => { 10 | it('should render correctly', () => { 11 | const firstExpectedText = 'Не удалось загрузить вопросы'; 12 | const { withStoreComponent } = withStore(, {}); 13 | 14 | render(withStoreComponent); 15 | 16 | expect(screen.getByText(firstExpectedText)).toBeInTheDocument(); 17 | expect(screen.getByRole('button')).toBeInTheDocument(); 18 | }); 19 | 20 | it('should dispatch "fetchQuestionAction" when user clicked replay button', async () => { 21 | const { withStoreComponent, mockStore, mockAxiosAdapter } = withStore(, {}); 22 | mockAxiosAdapter.onGet(APIRoute.Questions).reply(200, []); 23 | 24 | render(withStoreComponent); 25 | await userEvent.click(screen.getByRole('button')); 26 | const actions = extractActionsTypes(mockStore.getActions()); 27 | 28 | expect(actions).toEqual([ 29 | fetchQuestionAction.pending.type, 30 | fetchQuestionAction.fulfilled.type, 31 | ]); 32 | 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /markup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Угадай мелодию 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | 26 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/utils/mocks.ts: -------------------------------------------------------------------------------- 1 | import {music, system, name, internet} from 'faker'; 2 | import {AuthorizationStatus, GameType} from '../const'; 3 | import {QuestionArtist, QuestionGenre} from '../types/question'; 4 | import { Action } from 'redux'; 5 | import { ThunkDispatch } from 'redux-thunk'; 6 | import { createAPI } from '../services/api'; 7 | import { State } from '../types/state'; 8 | 9 | export type AppThunkDispatch = ThunkDispatch, Action>; 10 | 11 | export const makeFakeArtistQuestion = (): QuestionArtist => ({ 12 | type: GameType.Artist, 13 | song: { 14 | artist: name.title(), 15 | src: system.filePath(), 16 | }, 17 | answers: new Array(3).fill(null).map(() => ( 18 | { picture: internet.avatar(), artist: name.title() } 19 | )), 20 | } as QuestionArtist); 21 | 22 | export const makeFakeGenreQuestion = (): QuestionGenre => ({ 23 | type: GameType.Genre, 24 | genre: music.genre(), 25 | answers: new Array(4).fill(null).map(() => ( 26 | { src: system.filePath(), genre: music.genre() }), 27 | ), 28 | } as QuestionGenre); 29 | 30 | export const extractActionsTypes = (actions: Action[]) => actions.map(({ type }) => type); 31 | 32 | export const makeFakeStore = (initialState?: Partial): State => ({ 33 | USER: { authorizationStatus: AuthorizationStatus.NoAuth }, 34 | DATA: { isQuestionsDataLoading: false, questions: [], hasError: false }, 35 | GAME: {step: 10, mistakes: 2}, 36 | ...initialState ?? {}, 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/audio-player/audio-player.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import AudioPlayer from './audio-player'; 4 | import { internet } from 'faker'; 5 | 6 | describe('Component: AudioPlayer', () => { 7 | it('should render correctly', () => { 8 | const mockMelodyPath = internet.url(); 9 | const mockHandleClick = vi.fn(); 10 | 11 | render( 12 | , 17 | ); 18 | 19 | expect(screen.getByRole('button')).toBeInTheDocument(); 20 | expect(screen.getByTestId('audio')).toBeInTheDocument(); 21 | expect(screen.getByRole('button')).toBeDisabled(); 22 | expect(screen.getByRole('button')).toHaveClass('track__button--pause'); 23 | }); 24 | 25 | it('should play button enable when data loaded', async () => { 26 | const mockMelodyPath = internet.url(); 27 | const mockHandleClick = vi.fn(); 28 | HTMLMediaElement.prototype.play = vi.fn(); 29 | 30 | render( 31 | , 35 | ); 36 | fireEvent(screen.getByTestId('audio'), new Event('loadeddata')); 37 | await userEvent.click(screen.getByRole('button')); 38 | 39 | expect(mockHandleClick).toBeCalledTimes(1); 40 | expect(screen.getByRole('button')).not.toBeDisabled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Угадай мелодию 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | 26 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/pages/welcome-screen/welcome-screen.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from 'react-router-dom'; 2 | import {Helmet} from 'react-helmet-async'; 3 | import {useAppDispatch} from '../../hooks'; 4 | import {resetGame} from '../../store/game-process/game-process'; 5 | import {AppRoute} from '../../const'; 6 | 7 | type WelcomeScreenProps = { 8 | errorsCount: number; 9 | } 10 | 11 | function WelcomeScreen({errorsCount}: WelcomeScreenProps): JSX.Element { 12 | const navigate = useNavigate(); 13 | const dispatch = useAppDispatch(); 14 | 15 | return ( 16 |
17 | 18 | Угадай мелодию. Правила игры 19 | 20 |
21 | Угадай мелодию 22 |
23 | 34 |

Правила игры

35 |

Правила просты:

36 |
    37 |
  • Нужно ответить на все вопросы.
  • 38 |
  • Можно допустить {errorsCount} ошибки.
  • 39 |
40 |

Удачи!

41 |
42 | ); 43 | } 44 | 45 | export default WelcomeScreen; 46 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError} from 'axios'; 2 | import {StatusCodes} from 'http-status-codes'; 3 | import {toast} from 'react-toastify'; 4 | import {getToken} from './token'; 5 | 6 | type DetailMessageType = { 7 | type: string; 8 | message: string; 9 | } 10 | 11 | const StatusCodeMapping: Record = { 12 | [StatusCodes.BAD_REQUEST]: true, 13 | [StatusCodes.UNAUTHORIZED]: true, 14 | [StatusCodes.NOT_FOUND]: true 15 | }; 16 | 17 | const shouldDisplayError = (response: AxiosResponse) => !!StatusCodeMapping[response.status]; 18 | 19 | const BACKEND_URL = 'https://13.design.pages.academy/guess-melody'; 20 | const REQUEST_TIMEOUT = 5000; 21 | 22 | export const createAPI = (): AxiosInstance => { 23 | const api = axios.create({ 24 | baseURL: BACKEND_URL, 25 | timeout: REQUEST_TIMEOUT, 26 | }); 27 | 28 | api.interceptors.request.use( 29 | (config: AxiosRequestConfig) => { 30 | const token = getToken(); 31 | 32 | if (token && config.headers) { 33 | config.headers['x-token'] = token; 34 | } 35 | 36 | return config; 37 | }, 38 | ); 39 | 40 | api.interceptors.response.use( 41 | (response) => response, 42 | (error: AxiosError) => { 43 | if (error.response && shouldDisplayError(error.response)) { 44 | const detailMessage = (error.response.data); 45 | 46 | toast.warn(detailMessage.message); 47 | } 48 | 49 | throw error; 50 | } 51 | ); 52 | 53 | return api; 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/genre-question-list/genre-question-list.tsx: -------------------------------------------------------------------------------- 1 | import {FormEvent} from 'react'; 2 | import GenreQuestionItem from '../genre-question-item/genre-question-item'; 3 | import {useUserAnswers} from '../../hooks/use-user-answers'; 4 | import {QuestionGenre, UserGenreQuestionAnswer} from '../../types/question'; 5 | 6 | type GenreQuestionListProps = { 7 | question: QuestionGenre; 8 | onAnswer: (question: QuestionGenre, answers: UserGenreQuestionAnswer) => void; 9 | renderPlayer: (src: string, playerIndex: number) => JSX.Element; 10 | }; 11 | 12 | function GenreQuestionList(props: GenreQuestionListProps) { 13 | const {question, onAnswer, renderPlayer} = props; 14 | const {answers} = question; 15 | const [userAnswers, handleAnswerChange] = useUserAnswers(question); 16 | 17 | return ( 18 |
) => { 21 | evt.preventDefault(); 22 | onAnswer(question, userAnswers); 23 | }} 24 | > 25 | {answers.map((answer, id) => { 26 | const keyValue = `${id}-${answer.src}`; 27 | return ( 28 | 36 | ); 37 | })} 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default GenreQuestionList; 45 | -------------------------------------------------------------------------------- /src/components/audio-player/audio-player.tsx: -------------------------------------------------------------------------------- 1 | import {Fragment, useState, useEffect, useRef} from 'react'; 2 | import cn from 'classnames'; 3 | import {useElementListener} from '../../hooks/use-element-listener'; 4 | 5 | type AudioPlayerProps = { 6 | isPlaying: boolean; 7 | src: string; 8 | onPlayButtonClick: () => void; 9 | } 10 | 11 | function AudioPlayer({isPlaying, src, onPlayButtonClick}: AudioPlayerProps): JSX.Element { 12 | const [isLoaded, setIsLoaded] = useState(false); 13 | 14 | const audioRef = useRef(null); 15 | 16 | const handleDataLoaded = () => { 17 | setIsLoaded(true); 18 | }; 19 | 20 | useElementListener('loadeddata', audioRef, handleDataLoaded); 21 | 22 | useEffect(() => { 23 | const playerElement = audioRef.current; 24 | 25 | if (!isLoaded || !playerElement) { 26 | return; 27 | } 28 | 29 | if (isPlaying) { 30 | playerElement.play(); 31 | return; 32 | } 33 | 34 | playerElement.pause(); 35 | }, [isPlaying, isLoaded]); 36 | 37 | return ( 38 | 39 | 27 | 28 | 29 | 30 | 31 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/store/api-actions.ts: -------------------------------------------------------------------------------- 1 | import {AxiosInstance} from 'axios'; 2 | import {createAsyncThunk} from '@reduxjs/toolkit'; 3 | import {AppDispatch, State} from '../types/state.js'; 4 | import {Questions} from '../types/question'; 5 | import {redirectToRoute} from './action'; 6 | import {saveToken, dropToken} from '../services/token'; 7 | import {APIRoute, AppRoute} from '../const'; 8 | import {AuthData} from '../types/auth-data'; 9 | import {UserData} from '../types/user-data'; 10 | 11 | export const fetchQuestionAction = createAsyncThunk( 16 | 'data/fetchQuestions', 17 | async (_arg, {extra: api}) => { 18 | const {data} = await api.get(APIRoute.Questions); 19 | return data; 20 | }, 21 | ); 22 | 23 | export const checkAuthAction = createAsyncThunk( 28 | 'user/checkAuth', 29 | async (_arg, {extra: api}) => { 30 | await api.get(APIRoute.Login); 31 | }, 32 | ); 33 | 34 | export const loginAction = createAsyncThunk( 39 | 'user/login', 40 | async ({login: email, password}, {dispatch, extra: api}) => { 41 | const {data: {token}} = await api.post(APIRoute.Login, {email, password}); 42 | saveToken(token); 43 | dispatch(redirectToRoute(AppRoute.Result)); 44 | }, 45 | ); 46 | 47 | export const logoutAction = createAsyncThunk( 52 | 'user/logout', 53 | async (_arg, {extra: api}) => { 54 | await api.delete(APIRoute.Logout); 55 | dropToken(); 56 | }, 57 | ); 58 | 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guess-melody", 3 | "version": "13.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "start": "vite", 7 | "build": "tsc && vite build", 8 | "lint": "eslint src --ext ts,tsx", 9 | "preview": "vite preview", 10 | "test": "vitest --passWithNoTests" 11 | }, 12 | "dependencies": { 13 | "@reduxjs/toolkit": "1.9.5", 14 | "axios": "0.27.2", 15 | "classnames": "2.3.2", 16 | "history": "5.3.0", 17 | "http-status-codes": "2.2.0", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0", 20 | "react-helmet-async": "1.3.0", 21 | "react-redux": "8.1.1", 22 | "react-router-dom": "6.14.0", 23 | "react-toastify": "9.1.3" 24 | }, 25 | "devDependencies": { 26 | "@jedmao/redux-mock-store": "3.0.5", 27 | "@testing-library/jest-dom": "5.16.5", 28 | "@testing-library/react": "14.0.0", 29 | "@testing-library/user-event": "14.4.3", 30 | "@types/faker": "5.5.9", 31 | "@types/react": "18.2.14", 32 | "@types/react-dom": "18.2.6", 33 | "@types/react-redux": "7.1.25", 34 | "@types/testing-library__jest-dom": "5.14.6", 35 | "@typescript-eslint/eslint-plugin": "5.60.1", 36 | "@typescript-eslint/parser": "5.57.1", 37 | "@vitejs/plugin-react": "4.0.1", 38 | "axios-mock-adapter": "1.21.5", 39 | "eslint": "8.43.0", 40 | "eslint-config-htmlacademy": "9.1.1", 41 | "eslint-plugin-react-hooks": "4.6.0", 42 | "eslint-plugin-react-refresh": "0.4.1", 43 | "faker": "5.5.3", 44 | "jsdom": "22.1.0", 45 | "typescript": "5.0.4", 46 | "vite": "4.3.9", 47 | "vitest": "0.32.2" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /markup/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |
  • Можно допустить 3 ошибки.
  • 30 |
31 |

Удачи!

32 |
33 |
34 |
35 | 36 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /markup/result-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Угадай мелодию 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 | Выход 25 |
26 | 27 |

Вы настоящий меломан!

28 |

Вы ответили правильно на 6 вопросов и совершили 2 ошибки

29 | 30 |
31 |
32 |
33 | 34 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/pages/win-screen/win-screen.tsx: -------------------------------------------------------------------------------- 1 | import {Link, useNavigate} from 'react-router-dom'; 2 | import {Helmet} from 'react-helmet-async'; 3 | import {useAppSelector, useAppDispatch} from '../../hooks'; 4 | import {resetGame} from '../../store/game-process/game-process'; 5 | import {logoutAction} from '../../store/api-actions'; 6 | import {AppRoute} from '../../const'; 7 | import {getMistakeCount, getStep} from '../../store/game-process/selectors'; 8 | 9 | function WinScreen(): JSX.Element { 10 | const step = useAppSelector(getStep); 11 | const mistakes = useAppSelector(getMistakeCount); 12 | 13 | const dispatch = useAppDispatch(); 14 | const navigate = useNavigate(); 15 | 16 | const correctlyQuestionsCount = step - mistakes; 17 | 18 | return ( 19 |
20 | 21 | Угадай мелодию. Вы настоящий меломан! 22 | 23 |
24 | { 27 | evt.preventDefault(); 28 | dispatch(logoutAction()); 29 | }} 30 | to='/' 31 | > 32 | Выход 33 | 34 |
35 |
36 | Угадай мелодию 37 |
38 |

Вы настоящий меломан!

39 |

Вы ответили правильно на {correctlyQuestionsCount} вопросов и совершили {mistakes} ошибки

40 | 50 |
51 | ); 52 | } 53 | 54 | export default WinScreen; 55 | -------------------------------------------------------------------------------- /markup/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |
  • Можно допустить 3 ошибки.
  • 31 |
32 |

Удачи!

33 |
34 |
35 |
36 | 37 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/game.test.ts: -------------------------------------------------------------------------------- 1 | import { isArtistAnswerCorrect, isGenreAnswerCorrect } from './game'; 2 | import { makeFakeArtistQuestion, makeFakeGenreQuestion } from './utils/mocks'; 3 | 4 | describe('Business Logic: check user\'s answer', () => { 5 | describe('Function: isArtistAnswerCorrect', () => { 6 | it('should return "true" when answer is correct', () => { 7 | // Arrange 8 | const mockArtiestQuestion = makeFakeArtistQuestion(); 9 | const { artist: correctAnswer } = mockArtiestQuestion.song; 10 | 11 | // Act 12 | const result = isArtistAnswerCorrect(mockArtiestQuestion, correctAnswer); 13 | 14 | // Assert 15 | expect(result).toBe(true); 16 | }); 17 | 18 | it('should return "false" when answer is incorrect', () => { 19 | // Arrange 20 | const mockArtiestQuestion = makeFakeArtistQuestion(); 21 | const incorrectAnswer = 'unknown'; 22 | 23 | // Act 24 | const result = isArtistAnswerCorrect(mockArtiestQuestion, incorrectAnswer); 25 | 26 | // Assert 27 | expect(result).toBe(false); 28 | }); 29 | }); 30 | 31 | describe('Function: isGenreAnswerCorrect', () => { 32 | it('should return "true" when answer is correct', () => { 33 | // Arrange 34 | const mockGenreQuestion = makeFakeGenreQuestion(); 35 | const { answers } = mockGenreQuestion; 36 | const correctAnswer = answers.map((answer) => answer.genre === mockGenreQuestion.genre); 37 | 38 | // Act 39 | const result = isGenreAnswerCorrect(mockGenreQuestion, correctAnswer); 40 | 41 | // Assert 42 | expect(result).toBe(true); 43 | }); 44 | 45 | it('should be return "false" when answer is incorrect', () => { 46 | const mockGenreQuestion = makeFakeGenreQuestion(); 47 | const { answers } = mockGenreQuestion; 48 | const incorrectAnswer = answers.map((answer) => answer.genre !== mockGenreQuestion.genre); 49 | 50 | const result = isGenreAnswerCorrect(mockGenreQuestion, incorrectAnswer); 51 | 52 | expect(result).toBe(false); 53 | }); 54 | }); 55 | }); 56 | 57 | -------------------------------------------------------------------------------- /src/pages/game-over-screen/game-over-screen.test.tsx: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from 'history'; 2 | import { AppRoute } from '../../const'; 3 | import { withHistory, withStore } from '../../utils/mock-component'; 4 | import { render, screen } from '@testing-library/react'; 5 | import userEvent from '@testing-library/user-event'; 6 | import GameOverScreen from './game-over-screen'; 7 | import { Route, Routes } from 'react-router'; 8 | 9 | describe('Component: GameOverScreen', () => { 10 | const mockHistory = createMemoryHistory(); 11 | 12 | beforeEach(() => { 13 | mockHistory.push(AppRoute.Lose); 14 | }); 15 | 16 | it('should render correctly', () => { 17 | const { withStoreComponent } = withStore(); 18 | const preparedComponent = withHistory(withStoreComponent, mockHistory); 19 | const firstExpectedText = 'Какая жалость!'; 20 | const secondExpectedText = 'У вас закончились все попытки. Ничего, повезёт в следующий раз!'; 21 | const thirdExpectedText = 'Попробовать ещё раз'; 22 | 23 | render(preparedComponent); 24 | 25 | expect(screen.getByText(firstExpectedText)).toBeInTheDocument(); 26 | expect(screen.getByText(secondExpectedText)).toBeInTheDocument(); 27 | expect(screen.getByRole('button')).toBeInTheDocument(); 28 | expect(screen.getByText(thirdExpectedText)).toBeInTheDocument(); 29 | }); 30 | 31 | it('should redirect to game route when user click "Replay button"', async () => { 32 | const expectedText = 'game screen'; 33 | const mockGameRouteComponent = {expectedText}; 34 | const componentWithHistory = withHistory( 35 | 36 | } /> 37 | 38 | , 39 | mockHistory 40 | ); 41 | const { withStoreComponent } = withStore(componentWithHistory, {}); 42 | 43 | render(withStoreComponent); 44 | await userEvent.click(screen.getByRole('button')); 45 | 46 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/genre-question-item/genre-question-item.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import GenreQuestionItem from './genre-question-item'; 4 | 5 | describe('Component: GenreQuestionItem', () => { 6 | const expectedText = 'mockPlayer'; 7 | const mockAnswer = { src: 'fakePath', genre: 'fakeGenre' }; 8 | const mockPlayer = () => {expectedText}; 9 | const mockId = 2; 10 | const mockUserAnswer = false; 11 | const mockHandleChange = vi.fn(); 12 | 13 | it('should render correctly', () => { 14 | render( 15 | ); 22 | 23 | expect(screen.getByText(/Отметить/i)).toBeInTheDocument(); 24 | expect(screen.getByRole('checkbox')).toBeInTheDocument(); 25 | expect(screen.getByRole('checkbox')).not.toBeChecked(); 26 | expect(screen.getByText('mockPlayer')).toBeInTheDocument(); 27 | }); 28 | 29 | it('onChange should called when user choose answer', async () => { 30 | render( 31 | ); 38 | await userEvent.click(screen.getByRole('checkbox')); 39 | 40 | expect(mockHandleChange).toBeCalled(); 41 | expect(mockHandleChange).nthCalledWith(1, mockId, true); 42 | }); 43 | 44 | it('should checked when change prop userAnswer', () => { 45 | const { rerender } = render( 46 | ); 53 | 54 | rerender( 55 | ); 62 | 63 | expect(screen.getByRole('checkbox')).toBeChecked(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/components/private-route/private-route.test.tsx: -------------------------------------------------------------------------------- 1 | import { MemoryHistory, createMemoryHistory } from 'history'; 2 | import { AppRoute, AuthorizationStatus } from '../../const'; 3 | import { withHistory } from '../../utils/mock-component'; 4 | import { Route, Routes } from 'react-router-dom'; 5 | import PrivateRoute from './private-route'; 6 | import { render, screen } from '@testing-library/react'; 7 | 8 | describe('Component: PrivateRoute', () => { 9 | let mockHistory: MemoryHistory; 10 | 11 | beforeAll(() => { 12 | mockHistory = createMemoryHistory(); 13 | }); 14 | 15 | beforeEach(() => { 16 | mockHistory.push(AppRoute.Result); 17 | }); 18 | 19 | it('should render component for public route, when user not authorized', () => { 20 | const expectedText = 'public route'; 21 | const notExpectedText = 'private route'; 22 | const preparedComponent = withHistory( 23 | 24 | {expectedText}} /> 25 | 27 | {notExpectedText} 28 | 29 | } 30 | /> 31 | , 32 | mockHistory 33 | ); 34 | 35 | render(preparedComponent); 36 | 37 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 38 | expect(screen.queryByText(notExpectedText)).not.toBeInTheDocument(); 39 | }); 40 | 41 | it('should render component for private route, when user authorized', () => { 42 | const expectedText = 'private route'; 43 | const notExpectedText = 'public route'; 44 | const preparedComponent = withHistory( 45 | 46 | {notExpectedText}} /> 47 | 49 | {expectedText} 50 | 51 | } 52 | /> 53 | , 54 | mockHistory 55 | ); 56 | 57 | render(preparedComponent); 58 | 59 | expect(screen.getByText(expectedText)).toBeInTheDocument(); 60 | expect(screen.queryByText(notExpectedText)).not.toBeInTheDocument(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Угадай мелодию 2 | 3 | Учебный демо-проект от HTML Academy для 13-го потока профессионального онлайн‑курса «React. Разработка сложных клиентских приложений». 4 | 5 | ### Как пользоваться репозиторием 6 | 7 | Первый вариант, это изучать коммиты [в веб-интерфейсе GitHub в master-ветке потока](https://github.com/htmlacademy-react/guess-melody-13/tree/main). 8 | 9 | Второй вариант, изучать коммиты локально. Для этого нужно: 10 | 1. Склонировать репозиторий на свой компьютер. Именно склонировать, а не скачать архив. 11 | 12 | 2. Открыть папку репозитория в терминале, который поддерживает Git. 13 | 14 | 3. Убедиться, что ветка `master`. 15 | 16 | 4. С помощью команды `git log --oneline` посмотреть список коммитов. Коммиты идут сверху вниз от новых к старым, выглядит это примерно вот так: 17 | ```bash 18 | c0ea9d8 1.2 Создаст функцию для генерации разметки меню WIP 19 | 1a34516 1.1 Подключит скрипт `src/main.js` к `public/index.html` 20 | 45f1ffe :hatching_chick: начальное состояние проекта 21 | ``` 22 | 23 | 5. Найти нужный коммит, скопировать его хэш (цифро-буквенный код в начале строки). 24 | 25 | 6. Встать на нужный коммит с помощью команды `git checkout хэш_коммита`. Например, вот так `git checkout c0ea9d8`. 26 | 27 | 7. Всё, изучайте код конкретного коммита. Чтобы вернуть всё как было, используйте команду `git checkout master`. 28 | 29 | > **Будьте внимательны**, если вы внесёте изменения в момент, когда изучаете коммиты, при попытке вернуться обратно, Git потребует от вас либо откатить изменения, либо закоммитить их. Пока вы не сделаете это, вернуться на master-ветку у вас не выйдет. 30 | 31 | ### Условные обозначения 32 | - Приписка `WIP` в названии коммита означает, что код в этом коммите может частично или полностью не работать, вызывать ошибки линтера, ломать сборку (`npm run build`) или не запускаться в режиме разработки (`npm run start`). Это нормально, потому что `WIP` — это аббревиатура `Work In Progress`, что дословно означает «работа в процессе». То есть такой коммит отражает некое промежуточное состояние нашего проекта. 33 | - Номер коммита `A. [B. ]C` расшифровывается, если не оговорено другое, следующим образом: 34 | - `A.` — номер модуля; 35 | - `[B. ]` — номер части домашнего задания. Квадратные скобки означают опциональность, потому что не все домашние задания даются в двух частях; 36 | - `C.` — порядковый номер. Исключительно для удобства. 37 | -------------------------------------------------------------------------------- /markup/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Угадай мелодию 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 40 |
41 |
42 | 43 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/pages/game-screen/game-screen.tsx: -------------------------------------------------------------------------------- 1 | import {Navigate} from 'react-router-dom'; 2 | import {useAppDispatch, useAppSelector} from '../../hooks'; 3 | import {incrementStep, checkUserAnswer} from '../../store/game-process/game-process'; 4 | import {AppRoute, GameType, MAX_MISTAKE_COUNT} from '../../const'; 5 | import ArtistQuestionScreen from '../artist-question-screen/artist-question-screen'; 6 | import GenreQuestionScreen from '../genre-question-screen/genre-question-screen'; 7 | import Mistakes from '../../components/mistakes/mistakes'; 8 | import {Question, UserAnswer} from '../../types/question'; 9 | import withAudioPlayer from '../../hocs/with-audio-player/with-audio-player'; 10 | import {getMistakeCount, getStep} from '../../store/game-process/selectors'; 11 | import {getQuestions} from '../../store/game-data/selectors'; 12 | 13 | const ArtistQuestionScreenWrapped = withAudioPlayer(ArtistQuestionScreen); 14 | const GenreQuestionScreenWrapped = withAudioPlayer(GenreQuestionScreen); 15 | 16 | function GameScreen(): JSX.Element { 17 | const step = useAppSelector(getStep); 18 | const mistakes = useAppSelector(getMistakeCount); 19 | const questions = useAppSelector(getQuestions); 20 | 21 | const question = questions[step]; 22 | 23 | const dispatch = useAppDispatch(); 24 | 25 | if (mistakes >= MAX_MISTAKE_COUNT) { 26 | return ; 27 | } 28 | 29 | if (step >= questions.length || !question) { 30 | return ; 31 | } 32 | 33 | const handleUserAnswer = (questionItem: Question, userAnswer: UserAnswer) => { 34 | dispatch(incrementStep()); 35 | dispatch(checkUserAnswer({question: questionItem, userAnswer})); 36 | }; 37 | 38 | switch (question.type) { 39 | case GameType.Artist: 40 | return ( 41 | 46 | 47 | 48 | ); 49 | case GameType.Genre: 50 | return ( 51 | 56 | 57 | 58 | ); 59 | default: 60 | return ; 61 | } 62 | } 63 | 64 | export default GameScreen; 65 | -------------------------------------------------------------------------------- /src/store/game-data/game-data.test.ts: -------------------------------------------------------------------------------- 1 | import { makeFakeArtistQuestion } from '../../utils/mocks'; 2 | import { fetchQuestionAction } from '../api-actions'; 3 | import { gameData } from './game-data'; 4 | 5 | describe('GameData Slice', () => { 6 | it('should return initial state with empty action', () => { 7 | const emptyAction = { type: '' }; 8 | const expectedState = { 9 | questions: [], 10 | isQuestionsDataLoading: false, 11 | hasError: false 12 | }; 13 | 14 | const result = gameData.reducer(expectedState, emptyAction); 15 | 16 | expect(result).toEqual(expectedState); 17 | }); 18 | 19 | it('should return default initial state with empty action', () => { 20 | const emptyAction = { type: '' }; 21 | const expectedState = { 22 | questions: [], 23 | isQuestionsDataLoading: false, 24 | hasError: false 25 | }; 26 | 27 | const result = gameData.reducer(undefined, emptyAction); 28 | 29 | expect(result).toEqual(expectedState); 30 | }); 31 | 32 | it('should set "isQuestionsDataLoading" to "true", "hasError" to "false" with "fetchQuestionAction.pending"', () => { 33 | const expectedState = { 34 | questions: [], 35 | isQuestionsDataLoading: true, 36 | hasError: false, 37 | }; 38 | 39 | const result = gameData.reducer(undefined, fetchQuestionAction.pending); 40 | 41 | expect(result).toEqual(expectedState); 42 | }); 43 | 44 | it('should set "questions" to array with question, "isQuestionsDataLoading" to "false" with "fetchQuestionAction.fulfilled"', () => { 45 | const mockArtistQuestion = makeFakeArtistQuestion(); 46 | const expectedState = { 47 | questions: [mockArtistQuestion], 48 | isQuestionsDataLoading: false, 49 | hasError: false, 50 | }; 51 | 52 | const result = gameData.reducer( 53 | undefined, 54 | fetchQuestionAction.fulfilled( 55 | [mockArtistQuestion], '', undefined) 56 | ); 57 | 58 | expect(result).toEqual(expectedState); 59 | }); 60 | 61 | it('should set "isQuestionsDataLoading" to "true", "hasError" to "true" with "fetchQuestionAction.rejected', () => { 62 | const expectedState = { 63 | questions: [], 64 | isQuestionsDataLoading: false, 65 | hasError: true, 66 | }; 67 | 68 | const result = gameData.reducer( 69 | undefined, 70 | fetchQuestionAction.rejected 71 | ); 72 | 73 | expect(result).toEqual(expectedState); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/store/game-process/game-process.test.ts: -------------------------------------------------------------------------------- 1 | import { FIRST_GAME_STEP } from '../../const'; 2 | import { makeFakeArtistQuestion } from '../../utils/mocks'; 3 | import { checkUserAnswer, gameProcess, incrementStep, resetGame } from './game-process'; 4 | 5 | describe('GameProcess Slice', () => { 6 | it('should return initial state with empty action', () => { 7 | const emptyAction = { type: '' }; 8 | const expectedState = { mistakes: 333, step: 10 }; 9 | 10 | const result = gameProcess.reducer(expectedState, emptyAction); 11 | 12 | expect(result).toEqual(expectedState); 13 | }); 14 | 15 | it('should return default initial state with empty action and undefined state', () => { 16 | const emptyAction = { type: '' }; 17 | const expectedState = { mistakes: 0, step: FIRST_GAME_STEP }; 18 | 19 | const result = gameProcess.reducer(undefined, emptyAction); 20 | 21 | expect(result).toEqual(expectedState); 22 | }); 23 | 24 | it('should reset game with "resetGame" action', () => { 25 | const initialState = { mistakes: 333, step: 10 }; 26 | const expectedState = { mistakes: 0, step: FIRST_GAME_STEP }; 27 | 28 | const result = gameProcess.reducer(initialState, resetGame); 29 | 30 | expect(result).toEqual(expectedState); 31 | }); 32 | 33 | it('should increment step with "incrementStep" action', () => { 34 | const initialState = { mistakes: 333, step: 4 }; 35 | const expectedStep = 5; 36 | 37 | const result = gameProcess.reducer(initialState, incrementStep); 38 | 39 | expect(result.step).toBe(expectedStep); 40 | }); 41 | 42 | it('should not increment mistake count with "checkUserAnswer" action and correct answer', () => { 43 | const initialState = { mistakes: 0, step: 4 }; 44 | const expectedMistakeCount = 0; 45 | const question = makeFakeArtistQuestion(); 46 | const { artist: userAnswer } = question.song; 47 | 48 | const result = gameProcess.reducer(initialState, checkUserAnswer({ question, userAnswer })); 49 | 50 | expect(result.mistakes).toBe(expectedMistakeCount); 51 | }); 52 | 53 | it('should increment mistake count with "checkUserAnswer" action and not correct answer', () => { 54 | const initialState = { mistakes: 0, step: 4 }; 55 | const expectedMistakeCount = 1; 56 | const question = makeFakeArtistQuestion(); 57 | const userAnswer = 'unknown artist'; 58 | 59 | const result = gameProcess.reducer(initialState, checkUserAnswer({ question, userAnswer })); 60 | 61 | expect(result.mistakes).toBe(expectedMistakeCount); 62 | }); 63 | 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/app/app.tsx: -------------------------------------------------------------------------------- 1 | import {Route, Routes} from 'react-router-dom'; 2 | import {HelmetProvider} from 'react-helmet-async'; 3 | import {useAppSelector} from '../../hooks'; 4 | import {AppRoute, MAX_MISTAKE_COUNT} from '../../const'; 5 | import WelcomeScreen from '../../pages/welcome-screen/welcome-screen'; 6 | import AuthScreen from '../../pages/auth-screen/auth-screen'; 7 | import GameOverScreen from '../../pages/game-over-screen/game-over-screen'; 8 | import WinScreen from '../../pages/win-screen/win-screen'; 9 | import NotFoundScreen from '../../pages/not-found-screen/not-found-screen'; 10 | import PrivateRoute from '../private-route/private-route'; 11 | import GameScreen from '../../pages/game-screen/game-screen'; 12 | import LoadingScreen from '../../pages/loading-screen/loading-screen'; 13 | import ErrorScreen from '../../pages/error-screen/error-screen'; 14 | import {getAuthorizationStatus, getAuthCheckedStatus} from '../../store/user-process/selectors'; 15 | import {getQuestionsDataLoadingStatus, getErrorStatus} from '../../store/game-data/selectors'; 16 | 17 | function App(): JSX.Element { 18 | const authorizationStatus = useAppSelector(getAuthorizationStatus); 19 | const isAuthChecked = useAppSelector(getAuthCheckedStatus); 20 | const isQuestionsDataLoading = useAppSelector(getQuestionsDataLoadingStatus); 21 | const hasError = useAppSelector(getErrorStatus); 22 | 23 | if (!isAuthChecked || isQuestionsDataLoading) { 24 | return ( 25 | 26 | ); 27 | } 28 | 29 | if (hasError) { 30 | return ( 31 | ); 32 | } 33 | 34 | return ( 35 | 36 | 37 | } 40 | /> 41 | } 44 | /> 45 | 51 | 52 | 53 | } 54 | /> 55 | } 58 | /> 59 | 63 | } 64 | /> 65 | } 68 | /> 69 | 70 | 71 | ); 72 | } 73 | 74 | export default App; 75 | -------------------------------------------------------------------------------- /src/pages/artist-question-screen/artist-question-screen.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, PropsWithChildren} from 'react'; 2 | import {Helmet} from 'react-helmet-async'; 3 | import Logo from '../../components/logo/logo'; 4 | import {QuestionArtist, UserArtistQuestionAnswer} from '../../types/question'; 5 | 6 | type ArtistQuestionScreenProps = PropsWithChildren<{ 7 | question: QuestionArtist; 8 | onAnswer: (question: QuestionArtist, answer: UserArtistQuestionAnswer) => void; 9 | renderPlayer: (src: string, playerIndex: number) => JSX.Element; 10 | }>; 11 | 12 | function ArtistQuestionScreen(props: ArtistQuestionScreenProps): JSX.Element { 13 | const {question, onAnswer, renderPlayer, children} = props; 14 | const {answers, song} = question; 15 | 16 | return ( 17 |
18 | 19 | Угадай мелодию. Кто исполняет эту песню? 20 | 21 |
22 | 23 | 24 | 25 | 28 | 29 | 30 | {children} 31 |
32 | 33 |
34 |

Кто исполняет эту песню?

35 |
36 |
37 | {renderPlayer(song.src, 0)} 38 |
39 |
40 | 41 |
42 | {answers.map((answer, id) => ( 43 |
44 | ) => { 51 | evt.preventDefault(); 52 | onAnswer(question, answer.artist); 53 | }} 54 | /> 55 | 59 |
60 | ))} 61 |
62 |
63 |
64 | ); 65 | } 66 | 67 | export default ArtistQuestionScreen; 68 | -------------------------------------------------------------------------------- /src/pages/auth-screen/auth-screen.tsx: -------------------------------------------------------------------------------- 1 | import {Helmet} from 'react-helmet-async'; 2 | import {useRef, FormEvent} from 'react'; 3 | import {useNavigate} from 'react-router-dom'; 4 | import {useAppDispatch} from '../../hooks'; 5 | import {loginAction} from '../../store/api-actions'; 6 | import {AppRoute} from '../../const'; 7 | 8 | function AuthScreen(): JSX.Element { 9 | const loginRef = useRef(null); 10 | const passwordRef = useRef(null); 11 | 12 | const dispatch = useAppDispatch(); 13 | const navigate = useNavigate(); 14 | 15 | const handleSubmit = (evt: FormEvent) => { 16 | evt.preventDefault(); 17 | 18 | if (loginRef.current !== null && passwordRef.current !== null) { 19 | dispatch(loginAction({ 20 | login: loginRef.current.value, 21 | password: passwordRef.current.value 22 | })); 23 | } 24 | }; 25 | 26 | return ( 27 |
28 | 29 | Угадай мелодию. Вы настоящий меломан! 30 | 31 |
32 | Угадай мелодию 33 |
34 |

Вы настоящий меломан!

35 |

Хотите узнать свой результат? Представьтесь!

36 |
41 |

42 | 43 | 51 |

52 |

53 | 54 | 62 | Неверный пароль 63 |

64 | 65 |
66 | 73 |
74 | ); 75 | } 76 | 77 | export default AuthScreen; 78 | -------------------------------------------------------------------------------- /src/store/user-process/user.process.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationStatus } from '../../const'; 2 | import { checkAuthAction, loginAction, logoutAction } from '../api-actions'; 3 | import { userProcess } from './user-process'; 4 | 5 | describe('UserProcess Slice', () => { 6 | it('should return initial state with empty action', () => { 7 | const emptyAction = { type: '' }; 8 | const expectedState = { authorizationStatus: AuthorizationStatus.Auth }; 9 | 10 | const result = userProcess.reducer(expectedState, emptyAction); 11 | 12 | expect(result).toEqual(expectedState); 13 | }); 14 | 15 | it('should return default initial state with empty action', () => { 16 | const emptyAction = { type: '' }; 17 | const expectedState = { authorizationStatus: AuthorizationStatus.Unknown }; 18 | 19 | const result = userProcess.reducer(undefined, emptyAction); 20 | 21 | expect(result).toEqual(expectedState); 22 | }); 23 | 24 | it('should set "Auth" with "checkAuthAction.fulfilled" action', () => { 25 | const initialState = { authorizationStatus: AuthorizationStatus.NoAuth }; 26 | const expectedState = { authorizationStatus: AuthorizationStatus.Auth }; 27 | 28 | const result = userProcess.reducer(initialState, checkAuthAction.fulfilled); 29 | 30 | expect(result).toEqual(expectedState); 31 | }); 32 | 33 | it('should set "NoAuth" with "checkAuthAction.rejected" action', () => { 34 | const initialState = { authorizationStatus: AuthorizationStatus.Auth }; 35 | const expectedState = { authorizationStatus: AuthorizationStatus.NoAuth }; 36 | 37 | const result = userProcess.reducer(initialState, checkAuthAction.rejected); 38 | 39 | expect(result).toEqual(expectedState); 40 | }); 41 | 42 | it('should set "Auth" with "loginAction.fulfilled" action', () => { 43 | const initialState = { authorizationStatus: AuthorizationStatus.NoAuth }; 44 | const expectedState = { authorizationStatus: AuthorizationStatus.Auth }; 45 | 46 | const result = userProcess.reducer(initialState, loginAction.fulfilled); 47 | 48 | expect(result).toEqual(expectedState); 49 | }); 50 | 51 | it('should set "NoAuth" with "loginAction.rejected" action', () => { 52 | const initialState = { authorizationStatus: AuthorizationStatus.Auth }; 53 | const expectedState = { authorizationStatus: AuthorizationStatus.NoAuth }; 54 | 55 | const result = userProcess.reducer(initialState, loginAction.rejected); 56 | 57 | expect(result).toEqual(expectedState); 58 | }); 59 | 60 | it('should set "NoAuth", with "logoutAction.fulfilled" action', () => { 61 | const initialState = { authorizationStatus: AuthorizationStatus.Auth }; 62 | const expectedState = { authorizationStatus: AuthorizationStatus.NoAuth }; 63 | 64 | const result = userProcess.reducer(initialState, logoutAction.fulfilled); 65 | 66 | expect(result).toEqual(expectedState); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/components/app/app.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen} from '@testing-library/react'; 2 | import { MemoryHistory, createMemoryHistory } from 'history'; 3 | import { MAX_MISTAKE_COUNT, AppRoute, AuthorizationStatus } from '../../const'; 4 | import App from './app'; 5 | import { withHistory, withStore } from '../../utils/mock-component'; 6 | import { makeFakeStore } from '../../utils/mocks'; 7 | 8 | describe('Application Routing', () => { 9 | let mockHistory: MemoryHistory; 10 | 11 | beforeEach(() => { 12 | mockHistory = createMemoryHistory(); 13 | }); 14 | 15 | it('should render "WelcomeScreen" when user navigate to "/"', () => { 16 | const withHistoryComponent = withHistory(, mockHistory); 17 | const { withStoreComponent } = withStore(withHistoryComponent, makeFakeStore()); 18 | mockHistory.push(AppRoute.Root); 19 | 20 | render(withStoreComponent); 21 | 22 | expect(screen.getByText(/Начать игру/i)).toBeInTheDocument(); 23 | expect(screen.getByText(new RegExp(`Можно допустить ${MAX_MISTAKE_COUNT}`, 'i'))).toBeInTheDocument(); 24 | }); 25 | 26 | it('should render "AuthScreen" when user navigate to "/login"', () => { 27 | const withHistoryComponent = withHistory(, mockHistory); 28 | const { withStoreComponent } = withStore(withHistoryComponent, makeFakeStore()); 29 | mockHistory.push(AppRoute.Login); 30 | 31 | render(withStoreComponent); 32 | 33 | expect(screen.getByText(/Сыграть ещё раз/i)).toBeInTheDocument(); 34 | expect(screen.getByText(/Хотите узнать свой результат\? Представьтесь!/i)).toBeInTheDocument(); 35 | expect(screen.getByLabelText(/Логин/i)).toBeInTheDocument(); 36 | expect(screen.getByLabelText(/Пароль/i)).toBeInTheDocument(); 37 | }); 38 | 39 | it('should render "WinScreen" when user navigate to "/result"', () => { 40 | const withHistoryComponent = withHistory(, mockHistory); 41 | const { withStoreComponent } = withStore(withHistoryComponent, makeFakeStore({ 42 | USER: { authorizationStatus: AuthorizationStatus.Auth } 43 | })); 44 | mockHistory.push(AppRoute.Result); 45 | 46 | render(withStoreComponent); 47 | 48 | expect(screen.getByText(/Вы настоящий меломан!/i)).toBeInTheDocument(); 49 | expect(screen.getByText(/Вы ответили правильно на 8 вопросов/i)).toBeInTheDocument(); 50 | expect(screen.getByText(/Сыграть ещё раз/i)).toBeInTheDocument(); 51 | }); 52 | 53 | it('should render "GameOverScreen" when user navigate to "/lose"', () => { 54 | const withHistoryComponent = withHistory(, mockHistory); 55 | const { withStoreComponent } = withStore(withHistoryComponent, makeFakeStore({ 56 | USER: { authorizationStatus: AuthorizationStatus.Auth } 57 | })); 58 | mockHistory.push(AppRoute.Lose); 59 | 60 | render(withStoreComponent); 61 | 62 | expect(screen.getByText(/Какая жалость!/i)).toBeInTheDocument(); 63 | expect(screen.getByText(/Попробовать ещё раз/i)).toBeInTheDocument(); 64 | expect(screen.getByText(/У вас закончились все попытки. Ничего, повезёт в следующий раз!/i)).toBeInTheDocument(); 65 | }); 66 | 67 | it('should render "NotFoundScreen" when user navigate to non-existent route', () => { 68 | const withHistoryComponent = withHistory(, mockHistory); 69 | const { withStoreComponent } = withStore(withHistoryComponent, makeFakeStore()); 70 | const unknownRoute = '/unknown-route'; 71 | mockHistory.push(unknownRoute); 72 | 73 | render(withStoreComponent); 74 | 75 | expect(screen.getByText('404. Page not found')).toBeInTheDocument(); 76 | expect(screen.getByText('Вернуться на главную')).toBeInTheDocument(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /markup/question-artist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |
34 |
35 |
36 |
37 |
38 |
39 | 40 |
41 |

Кто исполняет эту песню?

42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 |
50 | 51 |
52 |
53 | 54 | 58 |
59 | 60 |
61 | 62 | 66 |
67 | 68 |
69 | 70 | 74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /markup/question-genre.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 32 | 33 | 34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |

Выберите инди-рок треки

43 |
44 |
45 | 46 |
47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 | 63 |
64 |
65 | 66 |
67 | 68 |
69 | 70 |
71 |
72 | 73 | 74 |
75 |
76 | 77 |
78 | 79 |
80 | 81 |
82 |
83 | 84 | 85 |
86 |
87 | 88 | 89 |
90 |
91 |
92 |
93 |
94 | 95 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/store/api-actions.test.ts: -------------------------------------------------------------------------------- 1 | import { configureMockStore } from '@jedmao/redux-mock-store'; 2 | import { createAPI } from '../services/api'; 3 | import MockAdapter from 'axios-mock-adapter'; 4 | import thunk from 'redux-thunk'; 5 | import { Action } from 'redux'; 6 | import { AppThunkDispatch, extractActionsTypes, makeFakeArtistQuestion, makeFakeGenreQuestion } from '../utils/mocks'; 7 | import { State } from '../types/state'; 8 | import { checkAuthAction, fetchQuestionAction, loginAction, logoutAction } from './api-actions'; 9 | import { APIRoute } from '../const'; 10 | import { redirectToRoute } from './action'; 11 | import { AuthData } from '../types/auth-data'; 12 | import * as tokenStorage from '../services/token'; 13 | 14 | describe('Async actions', () => { 15 | const axios = createAPI(); 16 | const mockAxiosAdapter = new MockAdapter(axios); 17 | const middleware = [thunk.withExtraArgument(axios)]; 18 | const mockStoreCreator = configureMockStore, AppThunkDispatch>(middleware); 19 | let store: ReturnType; 20 | 21 | beforeEach(() => { 22 | store = mockStoreCreator({ DATA: { questions: [] }}); 23 | }); 24 | 25 | describe('checkAuthAction', () => { 26 | it('should dispatch "checkAuthAction.pending" and "checkAuthAction.fulfilled" with thunk "checkAuthAction', async () => { 27 | mockAxiosAdapter.onGet(APIRoute.Login).reply(200); 28 | 29 | await store.dispatch(checkAuthAction()); 30 | const actions = extractActionsTypes(store.getActions()); 31 | 32 | expect(actions).toEqual([ 33 | checkAuthAction.pending.type, 34 | checkAuthAction.fulfilled.type, 35 | ]); 36 | }); 37 | 38 | it('should dispatch "checkAuthAction.pending" and "checkAuthAction.rejected" when server response 400', async() => { 39 | mockAxiosAdapter.onGet(APIRoute.Login).reply(400); 40 | 41 | await store.dispatch(checkAuthAction()); 42 | const actions = extractActionsTypes(store.getActions()); 43 | 44 | expect(actions).toEqual([ 45 | checkAuthAction.pending.type, 46 | checkAuthAction.rejected.type, 47 | ]); 48 | }); 49 | }); 50 | 51 | describe('fetchQuestionAction', () => { 52 | it('should dispatch "fetchQuestionsAction.pending", "fetchQuestionAction.fulfilled", when server response 200', async() => { 53 | const mockQuestions = [makeFakeArtistQuestion(), makeFakeGenreQuestion()]; 54 | mockAxiosAdapter.onGet(APIRoute.Questions).reply(200, mockQuestions); 55 | 56 | await store.dispatch(fetchQuestionAction()); 57 | 58 | const emittedActions = store.getActions(); 59 | const extractedActionsTypes = extractActionsTypes(emittedActions); 60 | const fetchQuestionsActionFulfilled = emittedActions.at(1) as ReturnType; 61 | 62 | expect(extractedActionsTypes).toEqual([ 63 | fetchQuestionAction.pending.type, 64 | fetchQuestionAction.fulfilled.type, 65 | ]); 66 | 67 | expect(fetchQuestionsActionFulfilled.payload) 68 | .toEqual(mockQuestions); 69 | }); 70 | 71 | it('should dispatch "fetchQuestionAction.pending", "fetchQuestionAction.rejected" when server response 400', async () => { 72 | mockAxiosAdapter.onGet(APIRoute.Questions).reply(400, []); 73 | 74 | await store.dispatch(fetchQuestionAction()); 75 | const actions = extractActionsTypes(store.getActions()); 76 | 77 | expect(actions).toEqual([ 78 | fetchQuestionAction.pending.type, 79 | fetchQuestionAction.rejected.type, 80 | ]); 81 | }); 82 | }); 83 | 84 | describe('loginAction', () => { 85 | it('should dispatch "loginAction.pending", "redirectToRoute", "loginAction.fulfilled" when server response 200', async() => { 86 | const fakeUser: AuthData = { login: 'test@test.ru', password: '123456' }; 87 | const fakeServerReplay = { token: 'secret' }; 88 | mockAxiosAdapter.onPost(APIRoute.Login).reply(200, fakeServerReplay); 89 | 90 | await store.dispatch(loginAction(fakeUser)); 91 | const actions = extractActionsTypes(store.getActions()); 92 | 93 | expect(actions).toEqual([ 94 | loginAction.pending.type, 95 | redirectToRoute.type, 96 | loginAction.fulfilled.type, 97 | ]); 98 | }); 99 | 100 | it('should call "saveToken" once with the received token', async () => { 101 | const fakeUser: AuthData = { login: 'test@test.ru', password: '123456' }; 102 | const fakeServerReplay = { token: 'secret' }; 103 | mockAxiosAdapter.onPost(APIRoute.Login).reply(200, fakeServerReplay); 104 | const mockSaveToken = vi.spyOn(tokenStorage, 'saveToken'); 105 | 106 | await store.dispatch(loginAction(fakeUser)); 107 | 108 | expect(mockSaveToken).toBeCalledTimes(1); 109 | expect(mockSaveToken).toBeCalledWith(fakeServerReplay.token); 110 | }); 111 | 112 | }); 113 | 114 | describe('logoutAction', () => { 115 | it('should dispatch "logoutAction.pending", "logoutAction.fulfilled" when server response 204', async() => { 116 | mockAxiosAdapter.onDelete(APIRoute.Logout).reply(204); 117 | 118 | await store.dispatch(logoutAction()); 119 | const actions = extractActionsTypes(store.getActions()); 120 | 121 | expect(actions).toEqual([ 122 | logoutAction.pending.type, 123 | logoutAction.fulfilled.type, 124 | ]); 125 | }); 126 | 127 | it('should one call "dropToken" with "logoutAction"', async () => { 128 | mockAxiosAdapter.onDelete(APIRoute.Logout).reply(204); 129 | const mockDropToken = vi.spyOn(tokenStorage, 'dropToken'); 130 | 131 | await store.dispatch(logoutAction()); 132 | 133 | expect(mockDropToken).toBeCalledTimes(1); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /markup/img/logo-htmla.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/img/logo-htmla.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /markup/css/main.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! normalize.scss v0.1.0 | MIT License | based on git.io/normalize */@font-face{font-family:'Fira Sans';font-style:normal;font-weight:400;src:local('Fira Sans Regular'),local('FiraSans-Regular'),url(../fonts/FiraSans-Regular.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:400;src:local('Fira Sans Regular'),local('FiraSans-Regular'),url(../fonts/FiraSans-Regular-latin.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:500;src:local('Fira Sans Medium'),local('FiraSans-Medium'),url(../fonts/FiraSans-Medium.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:700;src:local('Fira Sans Bold'),local('FiraSans-Bold'),url(../fonts/FiraSans-Bold.woff2) format('woff2')}@-webkit-keyframes bounce{0%{-webkit-transform:translateY(-2000px);transform:translateY(-2000px)}70%{-webkit-transform:translateY(30px);transform:translateY(30px)}90%{-webkit-transform:translateY(-10px);transform:translateY(-10px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes bounce{0%{-webkit-transform:translateY(-2000px);transform:translateY(-2000px)}70%{-webkit-transform:translateY(30px);transform:translateY(30px)}90%{-webkit-transform:translateY(-10px);transform:translateY(-10px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes fadein{0%{opacity:0}to{opacity:1}}@-webkit-keyframes blink{0%{opacity:0}50%{opacity:1}}@keyframes blink{0%{opacity:0}50%{opacity:1}}@-webkit-keyframes shrink{0%,to{-webkit-transform:translateX(-50%) scale(1);transform:translateX(-50%) scale(1)}50%{-webkit-transform:translateX(-50%) scale(1.1);transform:translateX(-50%) scale(1.1)}}@keyframes shrink{0%,to{-webkit-transform:translateX(-50%) scale(1);transform:translateX(-50%) scale(1)}50%{-webkit-transform:translateX(-50%) scale(1.1);transform:translateX(-50%) scale(1.1)}}@keyframes fadein{0%{opacity:0}to{opacity:1}}body,html{height:100%}html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;position:relative;box-sizing:border-box;background:#1d121f linear-gradient(90deg,#1d121f,#270a17);font-family:sans-serif}body{margin:0;font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:400;font-size:16px;color:#230d1a;display:flex;justify-content:center;align-items:center;flex-direction:column;min-height:1000px;background:url(../img/vinyl.png) center no-repeat}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],block-borderlate{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0;max-width:100%;height:auto}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default;opacity:0.3;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}legend,td,th{padding:0}*,::after,::before{box-sizing:inherit}.content-box-component{box-sizing:content-box}.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;border:0;padding:0;white-space:nowrap;-webkit-clip-path:inset(100%);clip-path:inset(100%);clip:rect(0 0 0 0);overflow:hidden}.app{position:relative;margin-bottom:50px;padding-top:10px;width:780px}.footer,.main{display:flex;width:780px}.main{position:relative;align-items:center;flex-direction:column;flex-wrap:wrap;height:850px;justify-content:center}.footer{justify-content:space-between;padding-bottom:10px}.copyright{text-align:right;color:#f0eed5;white-space:nowrap}.copyright__logo:focus,.copyright__logo:hover{opacity:.8}.copyright__logo:active{opacity:.6}.copyright__text{margin:10px 0 0}.copyright__link{color:#ff9749}.copyright__link:focus,.copyright__link:hover{color:#f00000}.logo{width:186px;height:83px;font-size:0;background:url(../img/melody-logo.png) center no-repeat}.welcome{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.welcome__logo{margin:220px 0 0}.welcome__button{position:absolute;left:350px;top:420px;padding:0;width:0;height:0;background:0 0;border-color:transparent transparent transparent #ff9749;border-width:70px 0 70px 100px}.welcome__button:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.welcome__button:active{-webkit-transform:scale(1.04);transform:scale(1.04)}.welcome__rules-title{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;margin:300px 0 0}.welcome__text{margin:10px 0 0;text-align:center}.welcome__rules-list{margin:0;padding:0;list-style:none;text-align:center}.button{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500;padding:5px 20px;color:#230d1a;background-color:#f0eed5;border:3px solid #ff9749;border-radius:15px}.button:disabled{color:gray;border:4px solid #ffc396}.button:hover{color:#ff9749}.game,.game__header{display:flex;width:100%}.game{flex-direction:column;align-items:center;height:100%}.game__header{margin-bottom:210px}.game__back{position:relative;margin-left:50px;z-index:1;width:260px}.game__back::before{content:"";position:absolute;left:-50px;top:25px;width:40px;height:40px;background-image:url(../img/right-arrow.svg);background-repeat:no-repeat;background-size:40px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.game__back:hover{opacity:.8}.game__back:active,.game__back:focus{opacity:.6}.game__mistakes{display:flex;justify-content:flex-end;width:520px}.game__screen{position:relative;z-index:1;width:440px;text-align:center}.game__title{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;margin:0 0 30px}.game__answer{margin-left:15px;width:35px;height:49px}.game__check{display:block;width:35px;height:49px;font-size:0;background-image:url(../img/sprite.png);background-position:-5px -123px;cursor:pointer}.game__input:checked+.game__check{background-position:-5px -5px}.game__input:focus+.game__check{outline:-webkit-focus-ring-color auto 5px}.game__submit{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:00;margin-top:30px;padding:5px 20px;font-size:24px}.game__track{width:100%;display:flex}.game__artist{display:flex;justify-content:space-between;margin-top:120px}.timer,.timer__line{width:100%;height:100%}.timer{position:absolute;z-index:0;left:0;top:65px}.timer__line{fill:transparent;stroke:#ff9749;stroke-width:15px}.timer__value{width:260px;font-size:30px;color:#ff9749;text-align:center}.timer__dots{-webkit-animation:blink 1s steps(1,end) infinite;animation:blink 1s steps(1,end) infinite}.timer__value--finished{-webkit-animation:shrink 2s infinite;animation:shrink 2s infinite;color:red}.timer__value--finished .timer__dots{-webkit-animation:none;animation:none}.track{display:flex;width:100%;justify-content:space-between;margin-bottom:20px}.track__button{width:39px;height:53px;border:0;background-color:transparent;background-image:url(../img/sprite.png);cursor:pointer}.track__button--play{background-position:-5px -241px}.track__button--pause{background-position:-5px -294px}.track__status{height:55px;flex-grow:1;background-image:url(../img/player-background.png);background-repeat:no-repeat;background-size:cover;-webkit-animation:fadein 1s ease-out;animation:fadein 1s ease-out;-webkit-animation-iteration-count:1;animation-iteration-count:1}.artist,.artist__picture{width:134px;cursor:pointer}.artist{position:relative;color:#230d1a;text-align:center}.artist__picture{margin-bottom:5px;height:134px;background-color:#f0eed5;border:solid 5px #230d1a;border-radius:134px}.artist:hover .artist__picture,.artist__input:focus+.artist__name .artist__picture{border-color:#ff9749}.artist:active .artist__picture{border-color:#f00000}.artist__text{margin:0 0 5px}.artist__time{position:absolute;left:50%;top:50%;width:134px;color:#fff;font-weight:500;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.replay{position:relative;margin-top:20px;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.login__replay::after,.replay::after,.result__replay::after{position:absolute;content:"";width:27px;height:25px;top:50%;right:0;background-image:url(../img/sprite.png);background-position:-5px -357px;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.replay:focus,.replay:hover{color:#f00000}.login__replay:focus::after,.login__replay:hover::after,.replay:focus::after,.replay:hover::after,.result__replay:focus::after,.result__replay:hover::after{background-position:-5px -392px}.replay--error{margin-top: 10px}.login{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.login__logo{margin:220px 0 0}.login__title,.login__total{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;text-align:center;margin:10px 0 0}.login__total{font-size:20px;margin:30px 0 0;width:500px}.login__total--fail{margin-top:170px}.login__text{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;color:#230d1a;text-align:center;font-size:20px;margin:30px 0 0;width:500px}.login__form{width:450px;margin-top:150px;display:flex;flex-wrap:wrap;justify-content:center}.login__field{position:relative;flex-basis:50%;width:50%;margin:0}.login__field:last-of-type{text-align:right}.login__label{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500;margin-right:5px}.login__input{padding:5px 10px;width:150px;background-color:#f0eed5;border:3px solid #230d1a;border-radius:15px}.login__input:hover{border-color:#ff9749}.login__input--error,.login__input--error:hover{border-color:#f00000}.login__error{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:400;display:none;position:absolute;bottom:-20px;right:10px;font-size:14px;color:#f00000}.login__input--error+.login__error{display:inline}.login__button,.login__replay{margin-top:20px}.login__replay{position:relative;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.login__replay:focus,.login__replay:hover,.result__replay:focus,.result__replay:hover{color:#f00000}.result{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.result-logout__wrapper{display:flex;justify-content:flex-end;width:520px}.result-logout__link{position:relative;color:#ff9749;font-size:30px;text-decoration:none}.result-logout__link:focus,.result-logout__link:hover{color:#f00000;outline:0}.result__logo{margin:220px 0 0}.result__title,.result__total{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;text-align:center;margin:50px 0 0}.result__total{font-size:30px;margin:130px 0 0;width:500px}.result__total--fail{margin-top:170px}.result__text{margin:10px 0 0;font-size:16px}.result__replay{position:relative;margin-top:30px;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.result--artist .result__title{margin-top:20px}.result--artist .result__replay{margin-top:15px}.result--artist .result__total{margin:0}.result__answer{display:flex;justify-content:space-between;margin-top:20px;margin-bottom:20px;width:500px}.result--list .result__title{margin-bottom:10px;margin-top:230px;width:200px}.result--list .result__text{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;margin:0 0 5px}.result--list .result__total{position:relative;margin:0;font-size:16px}.result--list .result__total::before{position:absolute;content:"~";top:-35px;left:50%;font-size:50px;line-height:50px;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.result--list .result__replay{margin-top:10px}.result__list{padding:0;list-style:none;width:450px;margin:10px 0 20px;text-align:center;background-color:#f0eed5}.result__item--correct{color:#36b742;font-weight:500}.result__extra{margin-left:15px;font-style:italic}.result--genre .result__title{margin-bottom:10px;margin-top:230px;width:200px}.result--genre .result__text,.result--genre .result__total{margin:0}.result__correct{display:flex;margin-top:40px}.correct{width:35px;height:49px;background-image:url(../img/sprite.png);background-position:-5px -64px}.result__wrong{display:flex;margin-top:60px}.wrong{width:35px;height:49px;background-image:url(../img/sprite.png);background-position:-5px -182px}.result__genre{margin:30px 0 0}.modal{position:absolute;z-index:1;top:375px;padding:50px 20px;text-align:center;font-size:18px;background-color:#f0eed5;border:5px solid rgba(0,0,0,.8);border-radius:50px;-webkit-animation:bounce .6s;animation:bounce .6s}.modal--hidden{display:none}.modal__title{margin:0 0 15px}.modal__text{margin:0 0 20px}.modal__close{position:absolute;right:-30px;top:-30px;padding:0;width:30px;height:30px;background-color:transparent;background-image:url(../img/icon-cross.svg);background-repeat:no-repeat;background-size:30px 30px;border:0}.modal__close:hover{opacity:.8}.modal__close:focus{opacity:.6}.modal__button{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500}.modal__button:not(:last-of-type){margin-right:10px}.main-mistakes{font-size:2em;font-weight:400;right:40px;top:8px;position:absolute}.error__text{margin:0;text-align: center} 2 | -------------------------------------------------------------------------------- /public/css/main.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! normalize.scss v0.1.0 | MIT License | based on git.io/normalize */@font-face{font-family:'Fira Sans';font-style:normal;font-weight:400;src:local('Fira Sans Regular'),local('FiraSans-Regular'),url(../fonts/FiraSans-Regular.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:400;src:local('Fira Sans Regular'),local('FiraSans-Regular'),url(../fonts/FiraSans-Regular-latin.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:500;src:local('Fira Sans Medium'),local('FiraSans-Medium'),url(../fonts/FiraSans-Medium.woff2) format('woff2')}@font-face{font-family:'Fira Sans';font-style:normal;font-weight:700;src:local('Fira Sans Bold'),local('FiraSans-Bold'),url(../fonts/FiraSans-Bold.woff2) format('woff2')}@-webkit-keyframes bounce{0%{-webkit-transform:translateY(-2000px);transform:translateY(-2000px)}70%{-webkit-transform:translateY(30px);transform:translateY(30px)}90%{-webkit-transform:translateY(-10px);transform:translateY(-10px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes bounce{0%{-webkit-transform:translateY(-2000px);transform:translateY(-2000px)}70%{-webkit-transform:translateY(30px);transform:translateY(30px)}90%{-webkit-transform:translateY(-10px);transform:translateY(-10px)}to{-webkit-transform:translateY(0);transform:translateY(0)}}@-webkit-keyframes fadein{0%{opacity:0}to{opacity:1}}@-webkit-keyframes blink{0%{opacity:0}50%{opacity:1}}@keyframes blink{0%{opacity:0}50%{opacity:1}}@-webkit-keyframes shrink{0%,to{-webkit-transform:translateX(-50%) scale(1);transform:translateX(-50%) scale(1)}50%{-webkit-transform:translateX(-50%) scale(1.1);transform:translateX(-50%) scale(1.1)}}@keyframes shrink{0%,to{-webkit-transform:translateX(-50%) scale(1);transform:translateX(-50%) scale(1)}50%{-webkit-transform:translateX(-50%) scale(1.1);transform:translateX(-50%) scale(1.1)}}@keyframes fadein{0%{opacity:0}to{opacity:1}}body,html{height:100%}html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;position:relative;box-sizing:border-box;background:#1d121f linear-gradient(90deg,#1d121f,#270a17);font-family:sans-serif}body{margin:0;font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:400;font-size:16px;color:#230d1a;display:flex;justify-content:center;align-items:center;flex-direction:column;min-height:1000px;background:url(../img/vinyl.png) center no-repeat}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],block-borderlate{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0;max-width:100%;height:auto}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default;opacity:0.3;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}legend,td,th{padding:0}*,::after,::before{box-sizing:inherit}.content-box-component{box-sizing:content-box}.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;border:0;padding:0;white-space:nowrap;-webkit-clip-path:inset(100%);clip-path:inset(100%);clip:rect(0 0 0 0);overflow:hidden}.app{position:relative;margin-bottom:50px;padding-top:10px;width:780px}.footer,.main{display:flex;width:780px}.main{position:relative;align-items:center;flex-direction:column;flex-wrap:wrap;height:850px;justify-content:center}.footer{justify-content:space-between;padding-bottom:10px}.copyright{text-align:right;color:#f0eed5;white-space:nowrap}.copyright__logo:focus,.copyright__logo:hover{opacity:.8}.copyright__logo:active{opacity:.6}.copyright__text{margin:10px 0 0}.copyright__link{color:#ff9749}.copyright__link:focus,.copyright__link:hover{color:#f00000}.logo{width:186px;height:83px;font-size:0;background:url(../img/melody-logo.png) center no-repeat}.welcome{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.welcome__logo{margin:220px 0 0}.welcome__button{position:absolute;left:350px;top:420px;padding:0;width:0;height:0;background:0 0;border-color:transparent transparent transparent #ff9749;border-width:70px 0 70px 100px}.welcome__button:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.welcome__button:active{-webkit-transform:scale(1.04);transform:scale(1.04)}.welcome__rules-title{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;margin:300px 0 0}.welcome__text{margin:10px 0 0;text-align:center}.welcome__rules-list{margin:0;padding:0;list-style:none;text-align:center}.button{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500;padding:5px 20px;color:#230d1a;background-color:#f0eed5;border:3px solid #ff9749;border-radius:15px}.button:disabled{color:gray;border:4px solid #ffc396}.button:hover{color:#ff9749}.game,.game__header{display:flex;width:100%}.game{flex-direction:column;align-items:center;height:100%}.game__header{margin-bottom:210px}.game__back{position:relative;margin-left:50px;z-index:1;width:260px}.game__back::before{content:"";position:absolute;left:-50px;top:25px;width:40px;height:40px;background-image:url(../img/right-arrow.svg);background-repeat:no-repeat;background-size:40px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.game__back:hover{opacity:.8}.game__back:active,.game__back:focus{opacity:.6}.game__mistakes{display:flex;justify-content:flex-end;width:520px}.game__screen{position:relative;z-index:1;width:440px;text-align:center}.game__title{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;margin:0 0 30px}.game__answer{margin-left:15px;width:35px;height:49px}.game__check{display:block;width:35px;height:49px;font-size:0;background-image:url(../img/sprite.png);background-position:-5px -123px;cursor:pointer}.game__input:checked+.game__check{background-position:-5px -5px}.game__input:focus+.game__check{outline:-webkit-focus-ring-color auto 5px}.game__submit{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:00;margin-top:30px;padding:5px 20px;font-size:24px}.game__track{width:100%;display:flex}.game__artist{display:flex;justify-content:space-between;margin-top:120px}.timer,.timer__line{width:100%;height:100%}.timer{position:absolute;z-index:0;left:0;top:65px}.timer__line{fill:transparent;stroke:#ff9749;stroke-width:15px}.timer__value{width:260px;font-size:30px;color:#ff9749;text-align:center}.timer__dots{-webkit-animation:blink 1s steps(1,end) infinite;animation:blink 1s steps(1,end) infinite}.timer__value--finished{-webkit-animation:shrink 2s infinite;animation:shrink 2s infinite;color:red}.timer__value--finished .timer__dots{-webkit-animation:none;animation:none}.track{display:flex;width:100%;justify-content:space-between;margin-bottom:20px}.track__button{width:39px;height:53px;border:0;background-color:transparent;background-image:url(../img/sprite.png);cursor:pointer}.track__button--play{background-position:-5px -241px}.track__button--pause{background-position:-5px -294px}.track__status{height:55px;flex-grow:1;background-image:url(../img/player-background.png);background-repeat:no-repeat;background-size:cover;-webkit-animation:fadein 1s ease-out;animation:fadein 1s ease-out;-webkit-animation-iteration-count:1;animation-iteration-count:1}.artist,.artist__picture{width:134px;cursor:pointer}.artist{position:relative;color:#230d1a;text-align:center}.artist__picture{margin-bottom:5px;height:134px;background-color:#f0eed5;border:solid 5px #230d1a;border-radius:134px}.artist:hover .artist__picture,.artist__input:focus+.artist__name .artist__picture{border-color:#ff9749}.artist:active .artist__picture{border-color:#f00000}.artist__text{margin:0 0 5px}.artist__time{position:absolute;left:50%;top:50%;width:134px;color:#fff;font-weight:500;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.replay{position:relative;margin-top:20px;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.login__replay::after,.replay::after,.result__replay::after{position:absolute;content:"";width:27px;height:25px;top:50%;right:0;background-image:url(../img/sprite.png);background-position:-5px -357px;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.replay:focus,.replay:hover{color:#f00000}.login__replay:focus::after,.login__replay:hover::after,.replay:focus::after,.replay:hover::after,.result__replay:focus::after,.result__replay:hover::after{background-position:-5px -392px}.replay--error{margin-top: 10px}.login{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.login__logo{margin:220px 0 0}.login__title,.login__total{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;text-align:center;margin:10px 0 0}.login__total{font-size:20px;margin:30px 0 0;width:500px}.login__total--fail{margin-top:170px}.login__text{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;color:#230d1a;text-align:center;font-size:20px;margin:30px 0 0;width:500px}.login__form{width:450px;margin-top:150px;display:flex;flex-wrap:wrap;justify-content:center}.login__field{position:relative;flex-basis:50%;width:50%;margin:0}.login__field:last-of-type{text-align:right}.login__label{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500;margin-right:5px}.login__input{padding:5px 10px;width:150px;background-color:#f0eed5;border:3px solid #230d1a;border-radius:15px}.login__input:hover{border-color:#ff9749}.login__input--error,.login__input--error:hover{border-color:#f00000}.login__error{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:400;display:none;position:absolute;bottom:-20px;right:10px;font-size:14px;color:#f00000}.login__input--error+.login__error{display:inline}.login__button,.login__replay{margin-top:20px}.login__replay{position:relative;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.login__replay:focus,.login__replay:hover,.result__replay:focus,.result__replay:hover{color:#f00000}.result{display:flex;align-items:center;flex-direction:column;flex-wrap:wrap;width:100%;height:100%}.result-logout__wrapper{display:flex;justify-content:flex-end;width:520px}.result-logout__link{position:relative;color:#ff9749;font-size:30px;text-decoration:none}.result-logout__link:focus,.result-logout__link:hover{color:#f00000;outline:0}.result__logo{margin:220px 0 0}.result__title,.result__total{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;font-size:33px;color:#230d1a;text-align:center;margin:50px 0 0}.result__total{font-size:30px;margin:130px 0 0;width:500px}.result__total--fail{margin-top:170px}.result__text{margin:10px 0 0;font-size:16px}.result__replay{position:relative;margin-top:30px;padding:0 40px 0 0;color:#ff9749;font-size:30px;border:0;background:0 0}.result--artist .result__title{margin-top:20px}.result--artist .result__replay{margin-top:15px}.result--artist .result__total{margin:0}.result__answer{display:flex;justify-content:space-between;margin-top:20px;margin-bottom:20px;width:500px}.result--list .result__title{margin-bottom:10px;margin-top:230px;width:200px}.result--list .result__text{font-family:"Fira Sans",Arial,sans-serif;font-style:italic;font-weight:400;margin:0 0 5px}.result--list .result__total{position:relative;margin:0;font-size:16px}.result--list .result__total::before{position:absolute;content:"~";top:-35px;left:50%;font-size:50px;line-height:50px;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.result--list .result__replay{margin-top:10px}.result__list{padding:0;list-style:none;width:450px;margin:10px 0 20px;text-align:center;background-color:#f0eed5}.result__item--correct{color:#36b742;font-weight:500}.result__extra{margin-left:15px;font-style:italic}.result--genre .result__title{margin-bottom:10px;margin-top:230px;width:200px}.result--genre .result__text,.result--genre .result__total{margin:0}.result__correct{display:flex;margin-top:40px}.correct{width:35px;height:49px;background-image:url(../img/sprite.png);background-position:-5px -64px}.result__wrong{display:flex;margin-top:60px}.wrong{width:35px;height:49px;background-image:url(../img/sprite.png);background-position:-5px -182px}.result__genre{margin:30px 0 0}.modal{position:absolute;z-index:1;top:375px;padding:50px 20px;text-align:center;font-size:18px;background-color:#f0eed5;border:5px solid rgba(0,0,0,.8);border-radius:50px;-webkit-animation:bounce .6s;animation:bounce .6s}.modal--hidden{display:none}.modal__title{margin:0 0 15px}.modal__text{margin:0 0 20px}.modal__close{position:absolute;right:-30px;top:-30px;padding:0;width:30px;height:30px;background-color:transparent;background-image:url(../img/icon-cross.svg);background-repeat:no-repeat;background-size:30px 30px;border:0}.modal__close:hover{opacity:.8}.modal__close:focus{opacity:.6}.modal__button{font-family:"Fira Sans",Arial,sans-serif;font-style:normal;font-weight:500}.modal__button:not(:last-of-type){margin-right:10px}.main-mistakes{font-size:2em;font-weight:400;right:40px;top:8px;position:absolute}.error__text{margin:0;text-align: center} 2 | -------------------------------------------------------------------------------- /markup/css/main.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /*! normalize.scss v0.1.0 | MIT License | based on git.io/normalize */ 3 | /* cyrillic */ 4 | @font-face { 5 | font-family: 'Fira Sans'; 6 | font-style: normal; 7 | font-weight: 400; 8 | src: local('Fira Sans Regular'), local('FiraSans-Regular'), url('../fonts/FiraSans-Regular.woff2') format('woff2'); 9 | } 10 | 11 | /* latin */ 12 | @font-face { 13 | font-family: 'Fira Sans'; 14 | font-style: normal; 15 | font-weight: 400; 16 | src: local('Fira Sans Regular'), local('FiraSans-Regular'), url('../fonts/FiraSans-Regular-latin.woff2') format('woff2'); 17 | } 18 | 19 | @font-face { 20 | font-family: 'Fira Sans'; 21 | font-style: normal; 22 | font-weight: 500; 23 | src: local('Fira Sans Medium'), local('FiraSans-Medium'), url('../fonts/FiraSans-Medium.woff2') format('woff2'); 24 | } 25 | 26 | @font-face { 27 | font-family: 'Fira Sans'; 28 | font-style: normal; 29 | font-weight: 700; 30 | src: local('Fira Sans Bold'), local('FiraSans-Bold'), url('../fonts/FiraSans-Bold.woff2') format('woff2'); 31 | } 32 | 33 | @-webkit-keyframes bounce { 34 | 0% { 35 | -webkit-transform: translateY(-2000px); 36 | transform: translateY(-2000px); 37 | } 38 | 70% { 39 | -webkit-transform: translateY(30px); 40 | transform: translateY(30px); 41 | } 42 | 90% { 43 | -webkit-transform: translateY(-10px); 44 | transform: translateY(-10px); 45 | } 46 | to { 47 | -webkit-transform: translateY(0); 48 | transform: translateY(0); 49 | } 50 | } 51 | 52 | @keyframes bounce { 53 | 0% { 54 | -webkit-transform: translateY(-2000px); 55 | transform: translateY(-2000px); 56 | } 57 | 70% { 58 | -webkit-transform: translateY(30px); 59 | transform: translateY(30px); 60 | } 61 | 90% { 62 | -webkit-transform: translateY(-10px); 63 | transform: translateY(-10px); 64 | } 65 | to { 66 | -webkit-transform: translateY(0); 67 | transform: translateY(0); 68 | } 69 | } 70 | 71 | @-webkit-keyframes fadein { 72 | 0% { 73 | opacity: 0; 74 | } 75 | to { 76 | opacity: 1; 77 | } 78 | } 79 | 80 | @-webkit-keyframes blink { 81 | 0% { 82 | opacity: 0; 83 | } 84 | 50% { 85 | opacity: 1; 86 | } 87 | } 88 | 89 | @keyframes blink { 90 | 0% { 91 | opacity: 0; 92 | } 93 | 50% { 94 | opacity: 1; 95 | } 96 | } 97 | 98 | @-webkit-keyframes shrink { 99 | 0%, to { 100 | -webkit-transform: translateX(-50%) scale(1); 101 | transform: translateX(-50%) scale(1); 102 | } 103 | 50% { 104 | -webkit-transform: translateX(-50%) scale(1.1); 105 | transform: translateX(-50%) scale(1.1); 106 | } 107 | } 108 | 109 | @keyframes shrink { 110 | 0%, to { 111 | -webkit-transform: translateX(-50%) scale(1); 112 | transform: translateX(-50%) scale(1); 113 | } 114 | 50% { 115 | -webkit-transform: translateX(-50%) scale(1.1); 116 | transform: translateX(-50%) scale(1.1); 117 | } 118 | } 119 | 120 | @keyframes fadein { 121 | 0% { 122 | opacity: 0; 123 | } 124 | to { 125 | opacity: 1; 126 | } 127 | } 128 | 129 | body, html { 130 | height: 100%; } 131 | 132 | html { 133 | -ms-text-size-adjust: 100%; 134 | -webkit-text-size-adjust: 100%; 135 | position: relative; 136 | box-sizing: border-box; 137 | background: #1d121f linear-gradient(90deg, #1d121f, #270a17); 138 | font-family: sans-serif; } 139 | 140 | body { 141 | margin: 0; 142 | font-family: "Fira Sans", "Arial", sans-serif; 143 | font-style: normal; 144 | font-weight: 400; 145 | font-size: 16px; 146 | color: #230d1a; 147 | display: flex; 148 | justify-content: center; 149 | align-items: center; 150 | flex-direction: column; 151 | min-height: 1000px; 152 | background: url(../img/vinyl.png) center no-repeat; } 153 | 154 | article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { 155 | display: block; } 156 | 157 | audio, canvas, progress, video { 158 | display: inline-block; 159 | vertical-align: baseline; } 160 | 161 | audio:not([controls]) { 162 | display: none; 163 | height: 0; } 164 | 165 | [hidden], block-borderlate { 166 | display: none; } 167 | 168 | a { 169 | background-color: transparent; } 170 | 171 | a:active, a:hover { 172 | outline: 0; } 173 | 174 | abbr[title] { 175 | border-bottom: 1px dotted; } 176 | 177 | b, strong { 178 | font-weight: 700; } 179 | 180 | dfn { 181 | font-style: italic; } 182 | 183 | h1 { 184 | font-size: 2em; 185 | margin: .67em 0; } 186 | 187 | mark { 188 | background: #ff0; 189 | color: #000; } 190 | 191 | small { 192 | font-size: 80%; } 193 | 194 | sub, sup { 195 | font-size: 75%; 196 | line-height: 0; 197 | position: relative; 198 | vertical-align: baseline; } 199 | 200 | sup { 201 | top: -.5em; } 202 | 203 | sub { 204 | bottom: -.25em; } 205 | 206 | img { 207 | border: 0; 208 | max-width: 100%; 209 | height: auto; } 210 | 211 | svg:not(:root) { 212 | overflow: hidden; } 213 | 214 | figure { 215 | margin: 1em 40px; } 216 | 217 | hr { 218 | box-sizing: content-box; 219 | height: 0; } 220 | 221 | pre { 222 | overflow: auto; } 223 | 224 | code, kbd, pre, samp { 225 | font-family: monospace, monospace; 226 | font-size: 1em; } 227 | 228 | button, input, optgroup, select, textarea { 229 | color: inherit; 230 | font: inherit; 231 | margin: 0; } 232 | 233 | button { 234 | overflow: visible; } 235 | 236 | button, select { 237 | text-transform: none; } 238 | 239 | button, html input[type=button], input[type=reset], input[type=submit] { 240 | -webkit-appearance: button; 241 | cursor: pointer; } 242 | 243 | button[disabled], html input[disabled] { 244 | cursor: default; 245 | opacity: 0.3; } 246 | 247 | button::-moz-focus-inner, input::-moz-focus-inner { 248 | border: 0; 249 | padding: 0; } 250 | 251 | input { 252 | line-height: normal; } 253 | 254 | input[type=checkbox], input[type=radio] { 255 | box-sizing: border-box; 256 | padding: 0; } 257 | 258 | input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { 259 | height: auto; } 260 | 261 | input[type=search] { 262 | -webkit-appearance: textfield; 263 | box-sizing: content-box; } 264 | 265 | input[type=search]::-webkit-search-cancel-button, input[type=search]::-webkit-search-decoration { 266 | -webkit-appearance: none; } 267 | 268 | fieldset { 269 | border: 1px solid silver; 270 | margin: 0 2px; 271 | padding: .35em .625em .75em; } 272 | 273 | legend { 274 | border: 0; } 275 | 276 | textarea { 277 | overflow: auto; } 278 | 279 | optgroup { 280 | font-weight: 700; } 281 | 282 | table { 283 | border-collapse: collapse; 284 | border-spacing: 0; } 285 | 286 | legend, td, th { 287 | padding: 0; } 288 | 289 | *, ::after, ::before { 290 | box-sizing: inherit; } 291 | 292 | .content-box-component { 293 | box-sizing: content-box; } 294 | 295 | .visually-hidden { 296 | position: absolute; 297 | width: 1px; 298 | height: 1px; 299 | margin: -1px; 300 | border: 0; 301 | padding: 0; 302 | white-space: nowrap; 303 | -webkit-clip-path: inset(100%); 304 | clip-path: inset(100%); 305 | clip: rect(0 0 0 0); 306 | overflow: hidden; } 307 | 308 | .app { 309 | position: relative; 310 | margin-bottom: 50px; 311 | padding-top: 10px; 312 | width: 780px; } 313 | 314 | .footer, .main { 315 | display: flex; 316 | width: 780px; } 317 | 318 | .main { 319 | position: relative; 320 | align-items: center; 321 | flex-direction: column; 322 | flex-wrap: wrap; 323 | height: 850px; 324 | justify-content: center; } 325 | 326 | .footer { 327 | justify-content: space-between; 328 | padding-bottom: 10px; } 329 | 330 | .copyright { 331 | text-align: right; 332 | color: #f0eed5; 333 | white-space: nowrap; } 334 | 335 | .copyright__logo:focus, .copyright__logo:hover { 336 | opacity: .8; 337 | } 338 | .copyright__logo:active { 339 | opacity: .6; } 340 | 341 | .copyright__text { 342 | margin: 10px 0 0; } 343 | 344 | .copyright__link { 345 | color: #ff9749; } 346 | 347 | .copyright__link:focus, .copyright__link:hover { 348 | color: #f00000; } 349 | 350 | .logo { 351 | width: 186px; 352 | height: 83px; 353 | font-size: 0; 354 | background: url(../img/melody-logo.png) center no-repeat; } 355 | 356 | .welcome { 357 | display: flex; 358 | align-items: center; 359 | flex-direction: column; 360 | flex-wrap: wrap; 361 | width: 100%; 362 | height: 100%; } 363 | 364 | .welcome__logo { 365 | margin: 220px 0 0; } 366 | 367 | .welcome__button { 368 | position: absolute; 369 | left: 350px; 370 | top: 420px; 371 | padding: 0; 372 | width: 0; 373 | height: 0; 374 | background: 0 0; 375 | border-color: transparent transparent transparent #ff9749; 376 | border-width: 70px 0 70px 100px; } 377 | 378 | .welcome__button:hover { 379 | -webkit-transform: scale(1.05); 380 | transform: scale(1.05); } 381 | 382 | .welcome__button:active { 383 | -webkit-transform: scale(1.04); 384 | transform: scale(1.04); } 385 | 386 | .welcome__rules-title { 387 | font-family: "Fira Sans", "Arial", sans-serif; 388 | font-style: italic; 389 | font-weight: 400; 390 | font-size: 33px; 391 | color: #230d1a; 392 | margin: 300px 0 0; } 393 | 394 | .welcome__text { 395 | margin: 10px 0 0; 396 | text-align: center; } 397 | 398 | .welcome__rules-list { 399 | margin: 0; 400 | padding: 0; 401 | list-style: none; 402 | text-align: center; } 403 | 404 | .button { 405 | font-family: "Fira Sans", "Arial", sans-serif; 406 | font-style: normal; 407 | font-weight: 500; 408 | padding: 5px 20px; 409 | color: #230d1a; 410 | background-color: #f0eed5; 411 | border: 3px solid #ff9749; 412 | border-radius: 15px; } 413 | 414 | .button:disabled { 415 | color: gray; 416 | border: 4px solid #ffc396; } 417 | 418 | .button:hover { 419 | color: #ff9749; } 420 | 421 | .game, .game__header { 422 | display: flex; 423 | width: 100%; } 424 | 425 | .game { 426 | flex-direction: column; 427 | align-items: center; 428 | height: 100%; } 429 | 430 | .game__header { 431 | margin-bottom: 210px; } 432 | 433 | .game__back { 434 | position: relative; 435 | margin-left: 50px; 436 | z-index: 1; 437 | width: 260px; } 438 | 439 | .game__back::before { 440 | content: ""; 441 | position: absolute; 442 | left: -50px; 443 | top: 25px; 444 | width: 40px; 445 | height: 40px; 446 | background-image: url(../img/right-arrow.svg); 447 | background-repeat: no-repeat; 448 | background-size: 40px; 449 | -webkit-transform: rotate(180deg); 450 | transform: rotate(180deg); } 451 | 452 | .game__back:hover { 453 | opacity: 0.8; } 454 | 455 | .game__back:active, .game__back:focus { 456 | opacity: 0.6; } 457 | 458 | .game__mistakes { 459 | display: flex; 460 | justify-content: flex-end; 461 | width: 520px; } 462 | 463 | .game__screen { 464 | position: relative; 465 | z-index: 1; 466 | width: 440px; 467 | text-align: center; } 468 | 469 | .game__title { 470 | font-family: "Fira Sans", "Arial", sans-serif; 471 | font-style: italic; 472 | font-weight: 400; 473 | font-size: 33px; 474 | color: #230d1a; 475 | margin: 0 0 30px; } 476 | 477 | .game__answer { 478 | margin-left: 15px; 479 | width: 35px; 480 | height: 49px; } 481 | 482 | .game__check { 483 | display: block; 484 | width: 35px; 485 | height: 49px; 486 | font-size: 0; 487 | background-image: url(../img/sprite.png); 488 | background-position: -5px -123px; 489 | cursor: pointer; 490 | } 491 | .game__input:checked+.game__check { 492 | background-position: -5px -5px; 493 | } 494 | .game__input:focus+.game__check { 495 | outline: -webkit-focus-ring-color auto 5px; 496 | } 497 | .game__submit { 498 | font-family: "Fira Sans", "Arial", sans-serif; 499 | font-style: italic; 500 | font-weight: 00; 501 | margin-top: 30px; 502 | padding: 5px 20px; 503 | font-size: 24px; } 504 | 505 | .game__track { 506 | width: 100%; 507 | display: flex; 508 | } 509 | .game__artist { 510 | display: flex; 511 | justify-content: space-between; 512 | margin-top: 120px; 513 | } 514 | .timer, .timer__line { 515 | width: 100%; 516 | height: 100%; } 517 | 518 | .timer { 519 | position: absolute; 520 | z-index: 0; 521 | left: 0; 522 | top: 65px; } 523 | 524 | .timer__line { 525 | fill: transparent; 526 | stroke: #ff9749; 527 | stroke-width: 15px; } 528 | 529 | .timer__value { 530 | width: 260px; 531 | font-size: 30px; 532 | color: #ff9749; 533 | text-align: center; } 534 | 535 | .timer__dots { 536 | -webkit-animation: blink 1000ms steps(1, end) infinite; 537 | animation: blink 1000ms steps(1, end) infinite; } 538 | 539 | .timer__value--finished { 540 | -webkit-animation: shrink 2000ms infinite; 541 | animation: shrink 2000ms infinite; 542 | color: red; } 543 | 544 | .timer__value--finished .timer__dots { 545 | -webkit-animation: none; 546 | animation: none; } 547 | 548 | .track { 549 | display: flex; 550 | width: 100%; 551 | justify-content: space-between; 552 | margin-bottom: 20px; } 553 | 554 | .track__button { 555 | width: 39px; 556 | height: 53px; 557 | border: 0; 558 | background-color: transparent; 559 | background-image: url(../img/sprite.png); 560 | cursor: pointer; } 561 | 562 | .track__button--play { 563 | background-position: -5px -241px; } 564 | 565 | .track__button--pause { 566 | background-position: -5px -294px; } 567 | 568 | .track__status { 569 | height: 55px; 570 | flex-grow: 1; 571 | background-image: url(../img/player-background.png); 572 | background-repeat: no-repeat; 573 | background-size: cover; 574 | -webkit-animation: fadein 1000ms ease-out; 575 | animation: fadein 1000ms ease-out; 576 | -webkit-animation-iteration-count: 1; 577 | animation-iteration-count: 1; } 578 | 579 | .artist, .artist__picture { 580 | width: 134px; 581 | cursor: pointer; 582 | } 583 | 584 | .artist { 585 | position: relative; 586 | color: #230d1a; 587 | text-align: center; } 588 | 589 | .artist__picture { 590 | margin-bottom: 5px; 591 | height: 134px; 592 | background-color: #f0eed5; 593 | border: solid 5px #230d1a; 594 | border-radius: 134px; } 595 | 596 | .artist:hover .artist__picture, .artist__input:focus+.artist__name .artist__picture { 597 | border-color: #ff9749; } 598 | 599 | .artist:active .artist__picture { 600 | border-color: #f00000; } 601 | 602 | .artist__text { 603 | margin: 0 0 5px; } 604 | 605 | .artist__time { 606 | position: absolute; 607 | left: 50%; 608 | top: 50%; 609 | width: 134px; 610 | color: #fff; 611 | font-weight: 500; 612 | -webkit-transform: translateX(-50%); 613 | transform: translateX(-50%); } 614 | 615 | .replay { 616 | position: relative; 617 | margin-top: 20px; 618 | padding: 0 40px 0 0; 619 | color: #ff9749; 620 | font-size: 30px; 621 | border: 0; 622 | background: 0 0; } 623 | 624 | .login__replay::after, .replay::after, .result__replay::after { 625 | position: absolute; 626 | content: ""; 627 | width: 27px; 628 | height: 25px; 629 | top: 50%; 630 | right: 0; 631 | background-image: url(../img/sprite.png); 632 | background-position: -5px -357px; 633 | -webkit-transform: translateY(-50%); 634 | transform: translateY(-50%); } 635 | 636 | .replay:focus, .replay:hover { 637 | color: #f00000; } 638 | 639 | .login__replay:focus::after, .login__replay:hover::after, .replay:focus::after, .replay:hover::after, .result__replay:focus::after, .result__replay:hover::after { 640 | background-position: -5px -392px; } 641 | 642 | .login { 643 | display: flex; 644 | align-items: center; 645 | flex-direction: column; 646 | flex-wrap: wrap; 647 | width: 100%; 648 | height: 100%; } 649 | 650 | .login__logo { 651 | margin: 220px 0 0; } 652 | 653 | .login__title, .login__total { 654 | font-family: "Fira Sans", "Arial", sans-serif; 655 | font-style: italic; 656 | font-weight: 400; 657 | font-size: 33px; 658 | color: #230d1a; 659 | text-align: center; 660 | margin: 10px 0 0; } 661 | 662 | .login__total { 663 | font-size: 20px; 664 | margin: 30px 0 0; 665 | width: 500px; } 666 | 667 | .login__total--fail { 668 | margin-top: 170px; } 669 | 670 | .login__text { 671 | font-family: "Fira Sans", "Arial", sans-serif; 672 | font-style: italic; 673 | font-weight: 400; 674 | color: #230d1a; 675 | text-align: center; 676 | font-size: 20px; 677 | margin: 30px 0 0; 678 | width: 500px; } 679 | 680 | .login__form { 681 | width: 450px; 682 | margin-top: 150px; 683 | display: flex; 684 | flex-wrap: wrap; 685 | justify-content: center; } 686 | 687 | .login__field { 688 | position: relative; 689 | flex-basis: 50%; 690 | width: 50%; 691 | margin: 0; } 692 | 693 | .login__field:last-of-type { 694 | text-align: right; } 695 | 696 | .login__label { 697 | font-family: "Fira Sans", "Arial", sans-serif; 698 | font-style: normal; 699 | font-weight: 500; 700 | margin-right: 5px; } 701 | 702 | .login__input { 703 | padding: 5px 10px; 704 | width: 150px; 705 | background-color: #f0eed5; 706 | border: 3px solid #230d1a; 707 | border-radius: 15px; } 708 | 709 | .login__input:hover { 710 | border-color: #ff9749; } 711 | 712 | .login__input--error, .login__input--error:hover { 713 | border-color: #f00000; } 714 | 715 | .login__error { 716 | font-family: "Fira Sans", "Arial", sans-serif; 717 | font-style: normal; 718 | font-weight: 400; 719 | display: none; 720 | position: absolute; 721 | bottom: -20px; 722 | right: 10px; 723 | font-size: 14px; 724 | color: #f00000; } 725 | 726 | .login__input--error+.login__error { 727 | display: inline; } 728 | 729 | .login__button, .login__replay { 730 | margin-top: 20px; } 731 | 732 | .login__replay { 733 | position: relative; 734 | padding: 0 40px 0 0; 735 | color: #ff9749; 736 | font-size: 30px; 737 | border: 0; 738 | background: 0 0; } 739 | 740 | .login__replay:focus, .login__replay:hover, .result__replay:focus, .result__replay:hover { 741 | color: #f00000; } 742 | 743 | .result { 744 | display: flex; 745 | align-items: center; 746 | flex-direction: column; 747 | flex-wrap: wrap; 748 | width: 100%; 749 | height: 100%; } 750 | 751 | .result-logout__wrapper { 752 | display: flex; 753 | justify-content: flex-end; 754 | width: 520px; } 755 | 756 | .result-logout__link { 757 | position: relative; 758 | color: #ff9749; 759 | font-size: 30px; 760 | text-decoration: none; } 761 | 762 | .result-logout__link:hover, 763 | .result-logout__link:focus { 764 | color: #f00000; 765 | outline: none; } 766 | 767 | .result__logo { 768 | margin: 220px 0 0; } 769 | 770 | .result__title, .result__total { 771 | font-family: "Fira Sans", "Arial", sans-serif; 772 | font-style: italic; 773 | font-weight: 400; 774 | font-size: 33px; 775 | color: #230d1a; 776 | text-align: center; 777 | margin: 50px 0 0; } 778 | 779 | .result__total { 780 | font-size: 30px; 781 | margin: 130px 0 0; 782 | width: 500px; } 783 | 784 | .result__total--fail { 785 | margin-top: 170px; } 786 | 787 | .result__text { 788 | margin: 10px 0 0; 789 | font-size: 16px; } 790 | 791 | .result__replay { 792 | position: relative; 793 | margin-top: 30px; 794 | padding: 0 40px 0 0; 795 | color: #ff9749; 796 | font-size: 30px; 797 | border: 0; 798 | background: 0 0; } 799 | 800 | .result--artist .result__title { 801 | margin-top: 20px; } 802 | 803 | .result--artist .result__replay { 804 | margin-top: 15px; } 805 | 806 | .result--artist .result__total { 807 | margin: 0; } 808 | 809 | .result__answer { 810 | display: flex; 811 | justify-content: space-between; 812 | margin-top: 20px; 813 | margin-bottom: 20px; 814 | width: 500px; } 815 | 816 | .result--list .result__title { 817 | margin-bottom: 10px; 818 | margin-top: 230px; 819 | width: 200px; } 820 | 821 | .result--list .result__text { 822 | font-family: "Fira Sans", "Arial", sans-serif; 823 | font-style: italic; 824 | font-weight: 400; 825 | margin: 0 0 5px; } 826 | 827 | .result--list .result__total { 828 | position: relative; 829 | margin: 0; 830 | font-size: 16px; } 831 | 832 | .result--list .result__total::before { 833 | position: absolute; 834 | content: "~"; 835 | top: -35px; 836 | left: 50%; 837 | font-size: 50px; 838 | line-height: 50px; 839 | -webkit-transform: translateX(-50%); 840 | transform: translateX(-50%); } 841 | 842 | .result--list .result__replay { 843 | margin-top: 10px; } 844 | 845 | .result__list { 846 | padding: 0; 847 | list-style: none; 848 | width: 450px; 849 | margin: 10px 0 20px; 850 | text-align: center; 851 | background-color: #f0eed5; } 852 | 853 | .result__item--correct { 854 | color: #36b742; 855 | font-weight: 500; } 856 | 857 | .result__extra { 858 | margin-left: 15px; 859 | font-style: italic; } 860 | 861 | .result--genre .result__title { 862 | margin-bottom: 10px; 863 | margin-top: 230px; 864 | width: 200px; } 865 | 866 | .result--genre .result__text, .result--genre .result__total { 867 | margin: 0; } 868 | 869 | .result__correct { 870 | display: flex; 871 | margin-top: 40px; } 872 | 873 | .correct { 874 | width: 35px; 875 | height: 49px; 876 | background-image: url(../img/sprite.png); 877 | background-position: -5px -64px; } 878 | 879 | .result__wrong { 880 | display: flex; 881 | margin-top: 60px; } 882 | 883 | .wrong { 884 | width: 35px; 885 | height: 49px; 886 | background-image: url(../img/sprite.png); 887 | background-position: -5px -182px; } 888 | 889 | .result__genre { 890 | margin: 30px 0 0; } 891 | 892 | .modal { 893 | position: absolute; 894 | z-index: 1; 895 | top: 375px; 896 | padding: 50px 20px; 897 | text-align: center; 898 | font-size: 18px; 899 | background-color: #f0eed5; 900 | border: 5px solid rgba(0, 0, 0, .8); 901 | border-radius: 50px; 902 | -webkit-animation: bounce .6s; 903 | animation: bounce .6s; } 904 | 905 | .modal--hidden { 906 | display: none; } 907 | 908 | .modal__title { 909 | margin: 0 0 15px; } 910 | 911 | .modal__text { 912 | margin: 0 0 20px; } 913 | 914 | .modal__close { 915 | position: absolute; 916 | right: -30px; 917 | top: -30px; 918 | padding: 0; 919 | width: 30px; 920 | height: 30px; 921 | background-color: transparent; 922 | background-image: url(../img/icon-cross.svg); 923 | background-repeat: no-repeat; 924 | background-size: 30px 30px; 925 | border: 0; } 926 | 927 | .modal__close:hover { 928 | opacity: 0.8; } 929 | 930 | .modal__close:focus { 931 | opacity: 0.6; } 932 | 933 | .modal__button { 934 | font-family: "Fira Sans", "Arial", sans-serif; 935 | font-style: normal; 936 | font-weight: 500; } 937 | 938 | .modal__button:not(:last-of-type) { 939 | margin-right: 10px; } 940 | 941 | .main-mistakes { 942 | font-size: 2em; 943 | font-weight: 400; 944 | right: 40px; 945 | top: 8px; 946 | position: absolute; } 947 | 948 | .replay--error { 949 | margin-top: 10px; 950 | } 951 | 952 | .error__text { 953 | margin: 0; 954 | text-align: center; 955 | } 956 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /*! normalize.scss v0.1.0 | MIT License | based on git.io/normalize */ 3 | /* cyrillic */ 4 | @font-face { 5 | font-family: 'Fira Sans'; 6 | font-style: normal; 7 | font-weight: 400; 8 | src: local('Fira Sans Regular'), local('FiraSans-Regular'), url('../fonts/FiraSans-Regular.woff2') format('woff2'); 9 | } 10 | 11 | /* latin */ 12 | @font-face { 13 | font-family: 'Fira Sans'; 14 | font-style: normal; 15 | font-weight: 400; 16 | src: local('Fira Sans Regular'), local('FiraSans-Regular'), url('../fonts/FiraSans-Regular-latin.woff2') format('woff2'); 17 | } 18 | 19 | @font-face { 20 | font-family: 'Fira Sans'; 21 | font-style: normal; 22 | font-weight: 500; 23 | src: local('Fira Sans Medium'), local('FiraSans-Medium'), url('../fonts/FiraSans-Medium.woff2') format('woff2'); 24 | } 25 | 26 | @font-face { 27 | font-family: 'Fira Sans'; 28 | font-style: normal; 29 | font-weight: 700; 30 | src: local('Fira Sans Bold'), local('FiraSans-Bold'), url('../fonts/FiraSans-Bold.woff2') format('woff2'); 31 | } 32 | 33 | @-webkit-keyframes bounce { 34 | 0% { 35 | -webkit-transform: translateY(-2000px); 36 | transform: translateY(-2000px); 37 | } 38 | 70% { 39 | -webkit-transform: translateY(30px); 40 | transform: translateY(30px); 41 | } 42 | 90% { 43 | -webkit-transform: translateY(-10px); 44 | transform: translateY(-10px); 45 | } 46 | to { 47 | -webkit-transform: translateY(0); 48 | transform: translateY(0); 49 | } 50 | } 51 | 52 | @keyframes bounce { 53 | 0% { 54 | -webkit-transform: translateY(-2000px); 55 | transform: translateY(-2000px); 56 | } 57 | 70% { 58 | -webkit-transform: translateY(30px); 59 | transform: translateY(30px); 60 | } 61 | 90% { 62 | -webkit-transform: translateY(-10px); 63 | transform: translateY(-10px); 64 | } 65 | to { 66 | -webkit-transform: translateY(0); 67 | transform: translateY(0); 68 | } 69 | } 70 | 71 | @-webkit-keyframes fadein { 72 | 0% { 73 | opacity: 0; 74 | } 75 | to { 76 | opacity: 1; 77 | } 78 | } 79 | 80 | @-webkit-keyframes blink { 81 | 0% { 82 | opacity: 0; 83 | } 84 | 50% { 85 | opacity: 1; 86 | } 87 | } 88 | 89 | @keyframes blink { 90 | 0% { 91 | opacity: 0; 92 | } 93 | 50% { 94 | opacity: 1; 95 | } 96 | } 97 | 98 | @-webkit-keyframes shrink { 99 | 0%, to { 100 | -webkit-transform: translateX(-50%) scale(1); 101 | transform: translateX(-50%) scale(1); 102 | } 103 | 50% { 104 | -webkit-transform: translateX(-50%) scale(1.1); 105 | transform: translateX(-50%) scale(1.1); 106 | } 107 | } 108 | 109 | @keyframes shrink { 110 | 0%, to { 111 | -webkit-transform: translateX(-50%) scale(1); 112 | transform: translateX(-50%) scale(1); 113 | } 114 | 50% { 115 | -webkit-transform: translateX(-50%) scale(1.1); 116 | transform: translateX(-50%) scale(1.1); 117 | } 118 | } 119 | 120 | @keyframes fadein { 121 | 0% { 122 | opacity: 0; 123 | } 124 | to { 125 | opacity: 1; 126 | } 127 | } 128 | 129 | body, html { 130 | height: 100%; } 131 | 132 | html { 133 | -ms-text-size-adjust: 100%; 134 | -webkit-text-size-adjust: 100%; 135 | position: relative; 136 | box-sizing: border-box; 137 | background: #1d121f linear-gradient(90deg, #1d121f, #270a17); 138 | font-family: sans-serif; } 139 | 140 | body { 141 | margin: 0; 142 | font-family: "Fira Sans", "Arial", sans-serif; 143 | font-style: normal; 144 | font-weight: 400; 145 | font-size: 16px; 146 | color: #230d1a; 147 | display: flex; 148 | justify-content: center; 149 | align-items: center; 150 | flex-direction: column; 151 | min-height: 1000px; 152 | background: url(../img/vinyl.png) center no-repeat; } 153 | 154 | article, aside, details, figcaption, figure, footer, header, hgroup, main, menu, nav, section, summary { 155 | display: block; } 156 | 157 | audio, canvas, progress, video { 158 | display: inline-block; 159 | vertical-align: baseline; } 160 | 161 | audio:not([controls]) { 162 | display: none; 163 | height: 0; } 164 | 165 | [hidden], block-borderlate { 166 | display: none; } 167 | 168 | a { 169 | background-color: transparent; } 170 | 171 | a:active, a:hover { 172 | outline: 0; } 173 | 174 | abbr[title] { 175 | border-bottom: 1px dotted; } 176 | 177 | b, strong { 178 | font-weight: 700; } 179 | 180 | dfn { 181 | font-style: italic; } 182 | 183 | h1 { 184 | font-size: 2em; 185 | margin: .67em 0; } 186 | 187 | mark { 188 | background: #ff0; 189 | color: #000; } 190 | 191 | small { 192 | font-size: 80%; } 193 | 194 | sub, sup { 195 | font-size: 75%; 196 | line-height: 0; 197 | position: relative; 198 | vertical-align: baseline; } 199 | 200 | sup { 201 | top: -.5em; } 202 | 203 | sub { 204 | bottom: -.25em; } 205 | 206 | img { 207 | border: 0; 208 | max-width: 100%; 209 | height: auto; } 210 | 211 | svg:not(:root) { 212 | overflow: hidden; } 213 | 214 | figure { 215 | margin: 1em 40px; } 216 | 217 | hr { 218 | box-sizing: content-box; 219 | height: 0; } 220 | 221 | pre { 222 | overflow: auto; } 223 | 224 | code, kbd, pre, samp { 225 | font-family: monospace, monospace; 226 | font-size: 1em; } 227 | 228 | button, input, optgroup, select, textarea { 229 | color: inherit; 230 | font: inherit; 231 | margin: 0; } 232 | 233 | button { 234 | overflow: visible; } 235 | 236 | button, select { 237 | text-transform: none; } 238 | 239 | button, html input[type=button], input[type=reset], input[type=submit] { 240 | -webkit-appearance: button; 241 | cursor: pointer; } 242 | 243 | button[disabled], html input[disabled] { 244 | cursor: default; 245 | opacity: 0.3; } 246 | 247 | button::-moz-focus-inner, input::-moz-focus-inner { 248 | border: 0; 249 | padding: 0; } 250 | 251 | input { 252 | line-height: normal; } 253 | 254 | input[type=checkbox], input[type=radio] { 255 | box-sizing: border-box; 256 | padding: 0; } 257 | 258 | input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { 259 | height: auto; } 260 | 261 | input[type=search] { 262 | -webkit-appearance: textfield; 263 | box-sizing: content-box; } 264 | 265 | input[type=search]::-webkit-search-cancel-button, input[type=search]::-webkit-search-decoration { 266 | -webkit-appearance: none; } 267 | 268 | fieldset { 269 | border: 1px solid silver; 270 | margin: 0 2px; 271 | padding: .35em .625em .75em; } 272 | 273 | legend { 274 | border: 0; } 275 | 276 | textarea { 277 | overflow: auto; } 278 | 279 | optgroup { 280 | font-weight: 700; } 281 | 282 | table { 283 | border-collapse: collapse; 284 | border-spacing: 0; } 285 | 286 | legend, td, th { 287 | padding: 0; } 288 | 289 | *, ::after, ::before { 290 | box-sizing: inherit; } 291 | 292 | .content-box-component { 293 | box-sizing: content-box; } 294 | 295 | .visually-hidden { 296 | position: absolute; 297 | width: 1px; 298 | height: 1px; 299 | margin: -1px; 300 | border: 0; 301 | padding: 0; 302 | white-space: nowrap; 303 | -webkit-clip-path: inset(100%); 304 | clip-path: inset(100%); 305 | clip: rect(0 0 0 0); 306 | overflow: hidden; } 307 | 308 | .app { 309 | position: relative; 310 | margin-bottom: 50px; 311 | padding-top: 10px; 312 | width: 780px; } 313 | 314 | .footer, .main { 315 | display: flex; 316 | width: 780px; } 317 | 318 | .main { 319 | position: relative; 320 | align-items: center; 321 | flex-direction: column; 322 | flex-wrap: wrap; 323 | height: 850px; 324 | justify-content: center; } 325 | 326 | .footer { 327 | justify-content: space-between; 328 | padding-bottom: 10px; } 329 | 330 | .copyright { 331 | text-align: right; 332 | color: #f0eed5; 333 | white-space: nowrap; } 334 | 335 | .copyright__logo:focus, .copyright__logo:hover { 336 | opacity: .8; 337 | } 338 | .copyright__logo:active { 339 | opacity: .6; } 340 | 341 | .copyright__text { 342 | margin: 10px 0 0; } 343 | 344 | .copyright__link { 345 | color: #ff9749; } 346 | 347 | .copyright__link:focus, .copyright__link:hover { 348 | color: #f00000; } 349 | 350 | .logo { 351 | width: 186px; 352 | height: 83px; 353 | font-size: 0; 354 | background: url(../img/melody-logo.png) center no-repeat; } 355 | 356 | .welcome { 357 | display: flex; 358 | align-items: center; 359 | flex-direction: column; 360 | flex-wrap: wrap; 361 | width: 100%; 362 | height: 100%; } 363 | 364 | .welcome__logo { 365 | margin: 220px 0 0; } 366 | 367 | .welcome__button { 368 | position: absolute; 369 | left: 350px; 370 | top: 420px; 371 | padding: 0; 372 | width: 0; 373 | height: 0; 374 | background: 0 0; 375 | border-color: transparent transparent transparent #ff9749; 376 | border-width: 70px 0 70px 100px; } 377 | 378 | .welcome__button:hover { 379 | -webkit-transform: scale(1.05); 380 | transform: scale(1.05); } 381 | 382 | .welcome__button:active { 383 | -webkit-transform: scale(1.04); 384 | transform: scale(1.04); } 385 | 386 | .welcome__rules-title { 387 | font-family: "Fira Sans", "Arial", sans-serif; 388 | font-style: italic; 389 | font-weight: 400; 390 | font-size: 33px; 391 | color: #230d1a; 392 | margin: 300px 0 0; } 393 | 394 | .welcome__text { 395 | margin: 10px 0 0; 396 | text-align: center; } 397 | 398 | .welcome__rules-list { 399 | margin: 0; 400 | padding: 0; 401 | list-style: none; 402 | text-align: center; } 403 | 404 | .button { 405 | font-family: "Fira Sans", "Arial", sans-serif; 406 | font-style: normal; 407 | font-weight: 500; 408 | padding: 5px 20px; 409 | color: #230d1a; 410 | background-color: #f0eed5; 411 | border: 3px solid #ff9749; 412 | border-radius: 15px; } 413 | 414 | .button:disabled { 415 | color: gray; 416 | border: 4px solid #ffc396; } 417 | 418 | .button:hover { 419 | color: #ff9749; } 420 | 421 | .game, .game__header { 422 | display: flex; 423 | width: 100%; } 424 | 425 | .game { 426 | flex-direction: column; 427 | align-items: center; 428 | height: 100%; } 429 | 430 | .game__header { 431 | margin-bottom: 210px; } 432 | 433 | .game__back { 434 | position: relative; 435 | margin-left: 50px; 436 | z-index: 1; 437 | width: 260px; } 438 | 439 | .game__back::before { 440 | content: ""; 441 | position: absolute; 442 | left: -50px; 443 | top: 25px; 444 | width: 40px; 445 | height: 40px; 446 | background-image: url(../img/right-arrow.svg); 447 | background-repeat: no-repeat; 448 | background-size: 40px; 449 | -webkit-transform: rotate(180deg); 450 | transform: rotate(180deg); } 451 | 452 | .game__back:hover { 453 | opacity: 0.8; } 454 | 455 | .game__back:active, .game__back:focus { 456 | opacity: 0.6; } 457 | 458 | .game__mistakes { 459 | display: flex; 460 | justify-content: flex-end; 461 | width: 520px; } 462 | 463 | .game__screen { 464 | position: relative; 465 | z-index: 1; 466 | width: 440px; 467 | text-align: center; } 468 | 469 | .game__title { 470 | font-family: "Fira Sans", "Arial", sans-serif; 471 | font-style: italic; 472 | font-weight: 400; 473 | font-size: 33px; 474 | color: #230d1a; 475 | margin: 0 0 30px; } 476 | 477 | .game__answer { 478 | margin-left: 15px; 479 | width: 35px; 480 | height: 49px; } 481 | 482 | .game__check { 483 | display: block; 484 | width: 35px; 485 | height: 49px; 486 | font-size: 0; 487 | background-image: url(../img/sprite.png); 488 | background-position: -5px -123px; 489 | cursor: pointer; 490 | } 491 | .game__input:checked+.game__check { 492 | background-position: -5px -5px; 493 | } 494 | .game__input:focus+.game__check { 495 | outline: -webkit-focus-ring-color auto 5px; 496 | } 497 | .game__submit { 498 | font-family: "Fira Sans", "Arial", sans-serif; 499 | font-style: italic; 500 | font-weight: 00; 501 | margin-top: 30px; 502 | padding: 5px 20px; 503 | font-size: 24px; } 504 | 505 | .game__track { 506 | width: 100%; 507 | display: flex; 508 | } 509 | .game__artist { 510 | display: flex; 511 | justify-content: space-between; 512 | margin-top: 120px; 513 | } 514 | .timer, .timer__line { 515 | width: 100%; 516 | height: 100%; } 517 | 518 | .timer { 519 | position: absolute; 520 | z-index: 0; 521 | left: 0; 522 | top: 65px; } 523 | 524 | .timer__line { 525 | fill: transparent; 526 | stroke: #ff9749; 527 | stroke-width: 15px; } 528 | 529 | .timer__value { 530 | width: 260px; 531 | font-size: 30px; 532 | color: #ff9749; 533 | text-align: center; } 534 | 535 | .timer__dots { 536 | -webkit-animation: blink 1000ms steps(1, end) infinite; 537 | animation: blink 1000ms steps(1, end) infinite; } 538 | 539 | .timer__value--finished { 540 | -webkit-animation: shrink 2000ms infinite; 541 | animation: shrink 2000ms infinite; 542 | color: red; } 543 | 544 | .timer__value--finished .timer__dots { 545 | -webkit-animation: none; 546 | animation: none; } 547 | 548 | .track { 549 | display: flex; 550 | width: 100%; 551 | justify-content: space-between; 552 | margin-bottom: 20px; } 553 | 554 | .track__button { 555 | width: 39px; 556 | height: 53px; 557 | border: 0; 558 | background-color: transparent; 559 | background-image: url(../img/sprite.png); 560 | cursor: pointer; } 561 | 562 | .track__button--play { 563 | background-position: -5px -241px; } 564 | 565 | .track__button--pause { 566 | background-position: -5px -294px; } 567 | 568 | .track__status { 569 | height: 55px; 570 | flex-grow: 1; 571 | background-image: url(../img/player-background.png); 572 | background-repeat: no-repeat; 573 | background-size: cover; 574 | -webkit-animation: fadein 1000ms ease-out; 575 | animation: fadein 1000ms ease-out; 576 | -webkit-animation-iteration-count: 1; 577 | animation-iteration-count: 1; } 578 | 579 | .artist, .artist__picture { 580 | width: 134px; 581 | cursor: pointer; 582 | } 583 | 584 | .artist { 585 | position: relative; 586 | color: #230d1a; 587 | text-align: center; } 588 | 589 | .artist__picture { 590 | margin-bottom: 5px; 591 | height: 134px; 592 | background-color: #f0eed5; 593 | border: solid 5px #230d1a; 594 | border-radius: 134px; } 595 | 596 | .artist:hover .artist__picture, .artist__input:focus+.artist__name .artist__picture { 597 | border-color: #ff9749; } 598 | 599 | .artist:active .artist__picture { 600 | border-color: #f00000; } 601 | 602 | .artist__text { 603 | margin: 0 0 5px; } 604 | 605 | .artist__time { 606 | position: absolute; 607 | left: 50%; 608 | top: 50%; 609 | width: 134px; 610 | color: #fff; 611 | font-weight: 500; 612 | -webkit-transform: translateX(-50%); 613 | transform: translateX(-50%); } 614 | 615 | .replay { 616 | position: relative; 617 | margin-top: 20px; 618 | padding: 0 40px 0 0; 619 | color: #ff9749; 620 | font-size: 30px; 621 | border: 0; 622 | background: 0 0; } 623 | 624 | .login__replay::after, .replay::after, .result__replay::after { 625 | position: absolute; 626 | content: ""; 627 | width: 27px; 628 | height: 25px; 629 | top: 50%; 630 | right: 0; 631 | background-image: url(../img/sprite.png); 632 | background-position: -5px -357px; 633 | -webkit-transform: translateY(-50%); 634 | transform: translateY(-50%); } 635 | 636 | .replay:focus, .replay:hover { 637 | color: #f00000; } 638 | 639 | .login__replay:focus::after, .login__replay:hover::after, .replay:focus::after, .replay:hover::after, .result__replay:focus::after, .result__replay:hover::after { 640 | background-position: -5px -392px; } 641 | 642 | .login { 643 | display: flex; 644 | align-items: center; 645 | flex-direction: column; 646 | flex-wrap: wrap; 647 | width: 100%; 648 | height: 100%; } 649 | 650 | .login__logo { 651 | margin: 220px 0 0; } 652 | 653 | .login__title, .login__total { 654 | font-family: "Fira Sans", "Arial", sans-serif; 655 | font-style: italic; 656 | font-weight: 400; 657 | font-size: 33px; 658 | color: #230d1a; 659 | text-align: center; 660 | margin: 10px 0 0; } 661 | 662 | .login__total { 663 | font-size: 20px; 664 | margin: 30px 0 0; 665 | width: 500px; } 666 | 667 | .login__total--fail { 668 | margin-top: 170px; } 669 | 670 | .login__text { 671 | font-family: "Fira Sans", "Arial", sans-serif; 672 | font-style: italic; 673 | font-weight: 400; 674 | color: #230d1a; 675 | text-align: center; 676 | font-size: 20px; 677 | margin: 30px 0 0; 678 | width: 500px; } 679 | 680 | .login__form { 681 | width: 450px; 682 | margin-top: 150px; 683 | display: flex; 684 | flex-wrap: wrap; 685 | justify-content: center; } 686 | 687 | .login__field { 688 | position: relative; 689 | flex-basis: 50%; 690 | width: 50%; 691 | margin: 0; } 692 | 693 | .login__field:last-of-type { 694 | text-align: right; } 695 | 696 | .login__label { 697 | font-family: "Fira Sans", "Arial", sans-serif; 698 | font-style: normal; 699 | font-weight: 500; 700 | margin-right: 5px; } 701 | 702 | .login__input { 703 | padding: 5px 10px; 704 | width: 150px; 705 | background-color: #f0eed5; 706 | border: 3px solid #230d1a; 707 | border-radius: 15px; } 708 | 709 | .login__input:hover { 710 | border-color: #ff9749; } 711 | 712 | .login__input--error, .login__input--error:hover { 713 | border-color: #f00000; } 714 | 715 | .login__error { 716 | font-family: "Fira Sans", "Arial", sans-serif; 717 | font-style: normal; 718 | font-weight: 400; 719 | display: none; 720 | position: absolute; 721 | bottom: -20px; 722 | right: 10px; 723 | font-size: 14px; 724 | color: #f00000; } 725 | 726 | .login__input--error+.login__error { 727 | display: inline; } 728 | 729 | .login__button, .login__replay { 730 | margin-top: 20px; } 731 | 732 | .login__replay { 733 | position: relative; 734 | padding: 0 40px 0 0; 735 | color: #ff9749; 736 | font-size: 30px; 737 | border: 0; 738 | background: 0 0; } 739 | 740 | .login__replay:focus, .login__replay:hover, .result__replay:focus, .result__replay:hover { 741 | color: #f00000; } 742 | 743 | .result { 744 | display: flex; 745 | align-items: center; 746 | flex-direction: column; 747 | flex-wrap: wrap; 748 | width: 100%; 749 | height: 100%; } 750 | 751 | .result-logout__wrapper { 752 | display: flex; 753 | justify-content: flex-end; 754 | width: 520px; } 755 | 756 | .result-logout__link { 757 | position: relative; 758 | color: #ff9749; 759 | font-size: 30px; 760 | text-decoration: none; } 761 | 762 | .result-logout__link:hover, 763 | .result-logout__link:focus { 764 | color: #f00000; 765 | outline: none; } 766 | 767 | .result__logo { 768 | margin: 220px 0 0; } 769 | 770 | .result__title, .result__total { 771 | font-family: "Fira Sans", "Arial", sans-serif; 772 | font-style: italic; 773 | font-weight: 400; 774 | font-size: 33px; 775 | color: #230d1a; 776 | text-align: center; 777 | margin: 50px 0 0; } 778 | 779 | .result__total { 780 | font-size: 30px; 781 | margin: 130px 0 0; 782 | width: 500px; } 783 | 784 | .result__total--fail { 785 | margin-top: 170px; } 786 | 787 | .result__text { 788 | margin: 10px 0 0; 789 | font-size: 16px; } 790 | 791 | .result__replay { 792 | position: relative; 793 | margin-top: 30px; 794 | padding: 0 40px 0 0; 795 | color: #ff9749; 796 | font-size: 30px; 797 | border: 0; 798 | background: 0 0; } 799 | 800 | .result--artist .result__title { 801 | margin-top: 20px; } 802 | 803 | .result--artist .result__replay { 804 | margin-top: 15px; } 805 | 806 | .result--artist .result__total { 807 | margin: 0; } 808 | 809 | .result__answer { 810 | display: flex; 811 | justify-content: space-between; 812 | margin-top: 20px; 813 | margin-bottom: 20px; 814 | width: 500px; } 815 | 816 | .result--list .result__title { 817 | margin-bottom: 10px; 818 | margin-top: 230px; 819 | width: 200px; } 820 | 821 | .result--list .result__text { 822 | font-family: "Fira Sans", "Arial", sans-serif; 823 | font-style: italic; 824 | font-weight: 400; 825 | margin: 0 0 5px; } 826 | 827 | .result--list .result__total { 828 | position: relative; 829 | margin: 0; 830 | font-size: 16px; } 831 | 832 | .result--list .result__total::before { 833 | position: absolute; 834 | content: "~"; 835 | top: -35px; 836 | left: 50%; 837 | font-size: 50px; 838 | line-height: 50px; 839 | -webkit-transform: translateX(-50%); 840 | transform: translateX(-50%); } 841 | 842 | .result--list .result__replay { 843 | margin-top: 10px; } 844 | 845 | .result__list { 846 | padding: 0; 847 | list-style: none; 848 | width: 450px; 849 | margin: 10px 0 20px; 850 | text-align: center; 851 | background-color: #f0eed5; } 852 | 853 | .result__item--correct { 854 | color: #36b742; 855 | font-weight: 500; } 856 | 857 | .result__extra { 858 | margin-left: 15px; 859 | font-style: italic; } 860 | 861 | .result--genre .result__title { 862 | margin-bottom: 10px; 863 | margin-top: 230px; 864 | width: 200px; } 865 | 866 | .result--genre .result__text, .result--genre .result__total { 867 | margin: 0; } 868 | 869 | .result__correct { 870 | display: flex; 871 | margin-top: 40px; } 872 | 873 | .correct { 874 | width: 35px; 875 | height: 49px; 876 | background-image: url(../img/sprite.png); 877 | background-position: -5px -64px; } 878 | 879 | .result__wrong { 880 | display: flex; 881 | margin-top: 60px; } 882 | 883 | .wrong { 884 | width: 35px; 885 | height: 49px; 886 | background-image: url(../img/sprite.png); 887 | background-position: -5px -182px; } 888 | 889 | .result__genre { 890 | margin: 30px 0 0; } 891 | 892 | .modal { 893 | position: absolute; 894 | z-index: 1; 895 | top: 375px; 896 | padding: 50px 20px; 897 | text-align: center; 898 | font-size: 18px; 899 | background-color: #f0eed5; 900 | border: 5px solid rgba(0, 0, 0, .8); 901 | border-radius: 50px; 902 | -webkit-animation: bounce .6s; 903 | animation: bounce .6s; } 904 | 905 | .modal--hidden { 906 | display: none; } 907 | 908 | .modal__title { 909 | margin: 0 0 15px; } 910 | 911 | .modal__text { 912 | margin: 0 0 20px; } 913 | 914 | .modal__close { 915 | position: absolute; 916 | right: -30px; 917 | top: -30px; 918 | padding: 0; 919 | width: 30px; 920 | height: 30px; 921 | background-color: transparent; 922 | background-image: url(../img/icon-cross.svg); 923 | background-repeat: no-repeat; 924 | background-size: 30px 30px; 925 | border: 0; } 926 | 927 | .modal__close:hover { 928 | opacity: 0.8; } 929 | 930 | .modal__close:focus { 931 | opacity: 0.6; } 932 | 933 | .modal__button { 934 | font-family: "Fira Sans", "Arial", sans-serif; 935 | font-style: normal; 936 | font-weight: 500; } 937 | 938 | .modal__button:not(:last-of-type) { 939 | margin-right: 10px; } 940 | 941 | .main-mistakes { 942 | font-size: 2em; 943 | font-weight: 400; 944 | right: 40px; 945 | top: 8px; 946 | position: absolute; } 947 | 948 | .replay--error { 949 | margin-top: 10px; 950 | } 951 | 952 | .error__text { 953 | margin: 0; 954 | text-align: center; 955 | } 956 | --------------------------------------------------------------------------------