├── src ├── shared │ ├── constants │ │ ├── bordeRadius.scss │ │ ├── fontWeight.scss │ │ └── colors.scss │ ├── types │ │ └── index.d.ts │ ├── UI │ │ ├── AvatarButton │ │ │ ├── types.ts │ │ │ ├── AvatarButton.module.scss │ │ │ └── AvatarButton.tsx │ │ ├── PromoSlider │ │ │ ├── PromoSlider.module.scss │ │ │ ├── types.ts │ │ │ └── PromoSlider.tsx │ │ ├── Forms │ │ │ ├── AuthForms │ │ │ │ ├── EmailForm │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── onSubmit.ts │ │ │ │ │ ├── EmailForm.tsx │ │ │ │ │ └── EmailForm.module.scss │ │ │ │ ├── CodeForm │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── CodeForm.module.scss │ │ │ │ │ ├── CodeForm.tsx │ │ │ │ │ └── onSubmit.ts │ │ │ │ ├── LoginForm │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── onSubmit.ts │ │ │ │ │ ├── LoginForm.tsx │ │ │ │ │ └── LoginForm.module.scss │ │ │ │ ├── ResetPasswordForm │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ResetPasswordForm.tsx │ │ │ │ │ └── ResetPasswordForm.module.scss │ │ │ │ └── SigUpForm │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── onSubmit.ts │ │ │ │ │ ├── SignUpForm.module.scss │ │ │ │ │ └── SignUpForm.tsx │ │ │ ├── SearchForm │ │ │ │ ├── type.ts │ │ │ │ ├── SearchForm.module.scss │ │ │ │ └── SearchForm.tsx │ │ │ └── FilesUploadIForm │ │ │ │ ├── types.ts │ │ │ │ ├── onSubmit.ts │ │ │ │ ├── FilesUploadForm.module.scss │ │ │ │ └── FilesUploadIForm.tsx │ │ ├── TrackSlider │ │ │ ├── types.ts │ │ │ ├── handlers │ │ │ │ └── shareHandler.ts │ │ │ ├── TrackSlider.module.scss │ │ │ └── TrackSlider.tsx │ │ ├── Player │ │ │ ├── utils │ │ │ │ ├── skipNext │ │ │ │ │ ├── types.ts │ │ │ │ │ └── skipNext.ts │ │ │ │ ├── onPlaying │ │ │ │ │ ├── types.ts │ │ │ │ │ └── onPlaying.ts │ │ │ │ ├── stopDragingProgress │ │ │ │ │ └── stopDragingProgress.ts │ │ │ │ ├── skipPrevious │ │ │ │ │ ├── types.ts │ │ │ │ │ └── skipPrevious.ts │ │ │ │ ├── checkVolume │ │ │ │ │ └── checkVolume.ts │ │ │ │ └── checkWidth │ │ │ │ │ └── checkWidth.ts │ │ │ ├── hooks │ │ │ │ ├── useDebounceOnPlayPause │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useDebounceOnPlayPause.tsx │ │ │ │ ├── useSkipNext │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useSkipNext.tsx │ │ │ │ ├── useDebounceOnMount │ │ │ │ │ ├── types.ts │ │ │ │ │ └── useDebounceOnPlay.tsx │ │ │ │ └── usePlayOnMount │ │ │ │ │ ├── types.ts │ │ │ │ │ └── usePlayOnMount.tsx │ │ │ ├── configs │ │ │ │ └── shareConfig.ts │ │ │ ├── handlers │ │ │ │ ├── downloadHandler │ │ │ │ │ └── downloadHandler.ts │ │ │ │ ├── likeHandler │ │ │ │ │ ├── types.ts │ │ │ │ │ └── likeHandler.ts │ │ │ │ ├── toggleVolumeControl │ │ │ │ │ └── toggleVolumeControl.ts │ │ │ │ └── shareHandler │ │ │ │ │ └── shareHandler.ts │ │ │ ├── Player.module.scss │ │ │ └── Player.tsx │ │ ├── Toast │ │ │ ├── types.ts │ │ │ └── Toast.tsx │ │ ├── TracksRow │ │ │ ├── types.ts │ │ │ ├── TracksRow.module.scss │ │ │ └── TracksRow.tsx │ │ ├── Inputs │ │ │ ├── AutoComplete │ │ │ │ ├── AutoComplete.module.scss │ │ │ │ ├── types.ts │ │ │ │ ├── hooks │ │ │ │ │ └── useDebounce.ts │ │ │ │ └── AutoComplete.tsx │ │ │ └── Select │ │ │ │ ├── types.ts │ │ │ │ └── Select.tsx │ │ ├── Navbar │ │ │ ├── Navbar.module.scss │ │ │ └── Navbar.tsx │ │ ├── Cursor │ │ │ └── Cursor.tsx │ │ └── Menu │ │ │ ├── Burger.module.scss │ │ │ └── Burger.tsx │ ├── HOC │ │ ├── PrivateRouter │ │ │ ├── type.ts │ │ │ └── PrivateRouter.tsx │ │ └── ErrorBoundary │ │ │ ├── types.ts │ │ │ └── ErrorBoundary.tsx │ ├── utils │ │ ├── forceDownload.ts │ │ ├── formatTime.ts │ │ ├── downloadResource.ts │ │ ├── turnOnPlayMode.ts │ │ ├── validateEmail.ts │ │ ├── __tests__ │ │ │ ├── formatTime.test.ts │ │ │ ├── validateEmail.test.ts │ │ │ └── validatePassword.test.ts │ │ ├── validatePassword.ts │ │ └── onSubmitNewPassword.ts │ ├── Request │ │ ├── types.ts │ │ └── Requets.ts │ ├── Redux │ │ ├── hooks.ts │ │ └── store.ts │ └── hooks │ │ ├── useCheckUser │ │ └── useCheckUser.ts │ │ ├── useAutocomplete │ │ └── useAutocomlete.ts │ │ ├── useGetLoaders │ │ ├── useGetLoaders.ts │ │ └── types.ts │ │ └── useTheme │ │ └── useTheme.ts ├── app │ ├── layout │ │ ├── MainLayout │ │ │ ├── MainLayout.module.scss │ │ │ ├── types.ts │ │ │ └── MainLayout.tsx │ │ └── AZLayout │ │ │ ├── AZLayout.module.scss │ │ │ ├── types.ts │ │ │ └── AZLayout.tsx │ ├── App.tsx │ └── routes │ │ ├── NAZRoutes │ │ └── NAZRoutes.tsx │ │ └── MainRoutes │ │ └── MainRoutes.tsx ├── pages │ ├── SettingsPage │ │ ├── config │ │ │ ├── avatarStyles.ts │ │ │ ├── themesOptions.ts │ │ │ └── langsOptions.ts │ │ ├── types.ts │ │ ├── handlers │ │ │ └── onSubmit.ts │ │ ├── SettingsPage.module.scss │ │ └── SettingsPage.tsx │ ├── TrackPage │ │ ├── TrackPage.module.scss │ │ ├── configs │ │ │ └── buttons.ts │ │ ├── handlers │ │ │ └── searchHandler.ts │ │ └── TrackPage.tsx │ ├── RadioPage │ │ ├── RadioPage.module.scss │ │ ├── configs │ │ │ └── buttons.ts │ │ ├── handlers │ │ │ └── searchHandler.ts │ │ └── RadioPage.tsx │ ├── SignupPage │ │ ├── configs │ │ │ └── rotatedPhrases.ts │ │ ├── SignupPage.tsx │ │ └── SignupPage.module.scss │ ├── FavoritesPage │ │ ├── FavoritesPage.module.scss │ │ └── FavoritesPage.tsx │ ├── CodePage │ │ ├── CodePage.tsx │ │ └── CodePage.module.scss │ ├── Error404 │ │ ├── Error404.module.scss │ │ └── Error404.tsx │ ├── ErrorPage │ │ ├── ErrorPage.module.scss │ │ └── ErrorPage.tsx │ ├── EmailPage │ │ ├── EmailPage.tsx │ │ └── EmailPage.module.scss │ ├── ResetPasswordPage │ │ ├── ResetPasswordPage.tsx │ │ └── ResetPasswordPage.module.scss │ └── LoginPage │ │ ├── LoginPage.module.scss │ │ └── LoginPage.tsx ├── entities │ ├── CurTracks │ │ ├── types.ts │ │ ├── slice.ts │ │ └── thunk.ts │ ├── Promo │ │ ├── types.ts │ │ ├── thunk.ts │ │ └── slice.ts │ ├── Favorite │ │ ├── types.ts │ │ ├── thunk.ts │ │ └── slice.ts │ ├── App │ │ ├── types.ts │ │ └── slice.ts │ ├── Radios │ │ ├── types.ts │ │ ├── slice.ts │ │ └── thunk.ts │ ├── Notification │ │ ├── types.ts │ │ └── slice.ts │ ├── Track │ │ ├── slice.ts │ │ ├── types.ts │ │ └── thunk.ts │ └── User │ │ ├── slice.ts │ │ ├── types.ts │ │ └── thunk.ts ├── i18n.js ├── main.tsx ├── test │ └── setup.ts └── widgets │ └── Messenger │ ├── Messenger.module.scss │ └── Messenger.tsx ├── .DS_Store ├── .gitattributes ├── public ├── .DS_Store ├── img │ ├── favicon.ico │ └── gradient.png ├── icons │ └── cover.svg ├── styles │ └── style.css └── locales │ ├── en │ └── translation.json │ └── ru │ └── translation.json ├── README.md ├── docker-compose.yml ├── vite.config.ts ├── .gitignore ├── nginx.conf ├── tsconfig.json ├── .dockerignore ├── .prettierrc ├── .github └── workflows │ └── github-actions-demo.yml ├── vitest.config.ts ├── Dockerfile ├── .eslintrc.json ├── index.html ├── package.json └── TESTING.md /src/shared/constants/bordeRadius.scss: -------------------------------------------------------------------------------- 1 | $round: 50%; 2 | -------------------------------------------------------------------------------- /src/shared/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.scss' 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ABurov30/nirvana-client/HEAD/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ABurov30/nirvana-client/HEAD/public/.DS_Store -------------------------------------------------------------------------------- /src/shared/constants/fontWeight.scss: -------------------------------------------------------------------------------- 1 | $semibold: 600; 2 | $medium: 500; 3 | $regular: 400; 4 | -------------------------------------------------------------------------------- /src/app/layout/MainLayout/MainLayout.module.scss: -------------------------------------------------------------------------------- 1 | .mainLayout { 2 | overflow: hidden; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/UI/AvatarButton/types.ts: -------------------------------------------------------------------------------- 1 | export interface AvatarProps { 2 | nickname: string 3 | } 4 | -------------------------------------------------------------------------------- /public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ABurov30/nirvana-client/HEAD/public/img/favicon.ico -------------------------------------------------------------------------------- /src/shared/UI/PromoSlider/PromoSlider.module.scss: -------------------------------------------------------------------------------- 1 | .img { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /public/img/gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ABurov30/nirvana-client/HEAD/public/img/gradient.png -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/EmailForm/types.ts: -------------------------------------------------------------------------------- 1 | export interface EmailForm { 2 | email: string 3 | } 4 | -------------------------------------------------------------------------------- /src/app/layout/AZLayout/AZLayout.module.scss: -------------------------------------------------------------------------------- 1 | .AZLayout { 2 | display: flex; 3 | overflow: hidden; 4 | } 5 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/CodeForm/types.ts: -------------------------------------------------------------------------------- 1 | export interface CodeForm { 2 | confirmationCode: string 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/SettingsPage/config/avatarStyles.ts: -------------------------------------------------------------------------------- 1 | export const avatarStyles = { 2 | bgcolor: '#BDBEBE', 3 | width: '3em', 4 | height: '3em' 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nirvana Client 2 | 3 | Client side of music streaming app by Andrey Burov 4 | 5 | To launch: npm run start 6 | 7 | To build: npm run build 8 | -------------------------------------------------------------------------------- /src/app/layout/AZLayout/types.ts: -------------------------------------------------------------------------------- 1 | import { ActiveType } from 'entities/User/types' 2 | 3 | export interface AZLayoutProps { 4 | user: ActiveType 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/TrackPage/TrackPage.module.scss: -------------------------------------------------------------------------------- 1 | .trackPage { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/HOC/PrivateRouter/type.ts: -------------------------------------------------------------------------------- 1 | export type IProps = { 2 | children?: React.ReactElement 3 | redirectPath?: string 4 | isAllowed?: boolean 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/RadioPage/RadioPage.module.scss: -------------------------------------------------------------------------------- 1 | .radioPage { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 100%; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/shared/UI/PromoSlider/types.ts: -------------------------------------------------------------------------------- 1 | import { Promo } from '../../../entities/Promo/types' 2 | 3 | export type PromoSliderProps = { 4 | promos: Promo[] 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/UI/TrackSlider/types.ts: -------------------------------------------------------------------------------- 1 | import { Track } from '../../../entities/Track/types' 2 | 3 | export type TrackSliderProps = { 4 | tracks: Track[] 5 | } 6 | -------------------------------------------------------------------------------- /src/app/layout/MainLayout/types.ts: -------------------------------------------------------------------------------- 1 | import { UserType } from '../../../entities/User/types' 2 | 3 | export interface MainLayoutProps { 4 | user: UserType 5 | } 6 | -------------------------------------------------------------------------------- /src/entities/CurTracks/types.ts: -------------------------------------------------------------------------------- 1 | import { Track } from 'entities/Track/types' 2 | 3 | export type CurTracks = { 4 | curTracks: Track[] 5 | position: number 6 | } 7 | -------------------------------------------------------------------------------- /src/entities/Promo/types.ts: -------------------------------------------------------------------------------- 1 | export interface Promo { 2 | id: string 3 | favicon: string 4 | } 5 | 6 | export interface PromoState { 7 | promo: Promo[] 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/skipNext/types.ts: -------------------------------------------------------------------------------- 1 | import { skipPreviousArgs } from '../skipPrevious/types' 2 | 3 | export interface skipNextArgs extends skipPreviousArgs {} 4 | -------------------------------------------------------------------------------- /src/shared/UI/Toast/types.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from '../../../entities/Notification/types' 2 | 3 | export interface ToastProps { 4 | notification: Notification 5 | } 6 | -------------------------------------------------------------------------------- /src/entities/Favorite/types.ts: -------------------------------------------------------------------------------- 1 | import { Track } from '../Track/types' 2 | 3 | export interface FavoriteState { 4 | favoriteTracks: Track[] 5 | favoriteRadios: Track[] 6 | } 7 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/LoginForm/types.ts: -------------------------------------------------------------------------------- 1 | import { EmailForm } from '../EmailForm/types' 2 | 3 | export interface LoginForm extends EmailForm { 4 | password: string 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/SettingsPage/types.ts: -------------------------------------------------------------------------------- 1 | import { EmailForm } from '../../shared/UI/Forms/AuthForms/EmailForm/types' 2 | 3 | export interface UserInfoForm extends EmailForm { 4 | nickname: string 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/CodeForm/CodeForm.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-direction: column; 6 | width: 80%; 7 | gap: 20px; 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | client: 5 | build: . 6 | container_name: music-client 7 | ports: 8 | - '5173:5173' 9 | restart: unless-stopped 10 | -------------------------------------------------------------------------------- /src/shared/UI/TracksRow/types.ts: -------------------------------------------------------------------------------- 1 | import { Track } from '../../../entities/Track/types' 2 | 3 | export interface TracksRowProps { 4 | title: string 5 | tracks: Track[] 6 | loadNext: () => void 7 | loadPrev: () => void 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useDebounceOnPlayPause/types.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject } from 'react' 2 | 3 | export type useDebounceOnPlayPauseArgs = { 4 | isPlaying: boolean 5 | audioElem: MutableRefObject 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/SignupPage/configs/rotatedPhrases.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next' 2 | 3 | export const rotatedPhrases = [ 4 | t('SignupPage.emotions'), 5 | t('SignupPage.feelings'), 6 | t('SignupPage.pleasure'), 7 | t('Shared.nirvana') 8 | ] 9 | -------------------------------------------------------------------------------- /src/entities/App/types.ts: -------------------------------------------------------------------------------- 1 | export type AppState = { 2 | theme: Theme 3 | isPlayMode: boolean 4 | } 5 | 6 | export enum Theme { 7 | light = 'light', 8 | dark = 'dark' 9 | } 10 | 11 | export enum Language { 12 | ru = 'ru', 13 | en = 'en' 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/Radios/types.ts: -------------------------------------------------------------------------------- 1 | import { Track } from 'entities/Track/types' 2 | 3 | export interface RadiosState { 4 | radios: Track[] 5 | } 6 | 7 | export interface SearchRadioForm { 8 | name: string 9 | tags: string 10 | country: string 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/UI/Player/configs/shareConfig.ts: -------------------------------------------------------------------------------- 1 | export const title = 'Check out best free music streaming app. Dive in Nirvana' 2 | export const hashtags = [ 3 | 'music', 4 | 'streaming', 5 | 'free', 6 | 'tracks', 7 | 'songs', 8 | 'radio' 9 | ] 10 | -------------------------------------------------------------------------------- /src/shared/utils/forceDownload.ts: -------------------------------------------------------------------------------- 1 | export function forceDownload(blob: string, filename: string) { 2 | const a = document.createElement('a') 3 | a.download = filename 4 | a.href = blob 5 | document.body.appendChild(a) 6 | a.click() 7 | a.remove() 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/Request/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios' 2 | 3 | export interface IRequestParams { 4 | method?: AxiosRequestConfig['method'] 5 | url: string 6 | data?: AxiosRequestConfig['data'] 7 | useMock?: boolean 8 | responseType?: string 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/HOC/ErrorBoundary/types.ts: -------------------------------------------------------------------------------- 1 | export interface IProps { 2 | children: React.ReactNode 3 | } 4 | 5 | export interface IState { 6 | hasError: boolean 7 | error: Error | null 8 | } 9 | 10 | export interface IErrorWithCode extends Error { 11 | code: number 12 | } 13 | -------------------------------------------------------------------------------- /src/entities/Notification/types.ts: -------------------------------------------------------------------------------- 1 | export interface Notification { 2 | severity: Severity | '' 3 | message: string 4 | isOpen?: boolean 5 | } 6 | 7 | export enum Severity { 8 | error = 'error', 9 | warning = 'warning', 10 | info = 'info', 11 | success = 'success' 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/utils/formatTime.ts: -------------------------------------------------------------------------------- 1 | export const formatTime = (seconds: number) => { 2 | const minutes = Math.floor(seconds / 60) 3 | const remainingSeconds = Math.floor(seconds % 60) 4 | const formattedTime = `${minutes}:${remainingSeconds 5 | .toString() 6 | .padStart(2, '0')}` 7 | return formattedTime 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/RadioPage/configs/buttons.ts: -------------------------------------------------------------------------------- 1 | import { IBlockButtonProps } from 'nirvana-uikit/dist/ui/Buttons/BlockButtons/BlockButton/types' 2 | 3 | import { t } from 'i18next' 4 | 5 | export const buttons = (): IBlockButtonProps[] => [ 6 | { 7 | text: t('Shared.search'), 8 | type: 'submit' 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /src/pages/TrackPage/configs/buttons.ts: -------------------------------------------------------------------------------- 1 | import { IBlockButtonProps } from 'nirvana-uikit/dist/ui/Buttons/BlockButtons/BlockButton/types' 2 | 3 | import { t } from 'i18next' 4 | 5 | export const buttons = (): IBlockButtonProps[] => [ 6 | { 7 | text: t('Shared.search'), 8 | type: 'submit' 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useSkipNext/types.ts: -------------------------------------------------------------------------------- 1 | import { skipNextArgs } from '../../utils/skipNext/types' 2 | 3 | export interface useSkipNextArgs extends skipNextArgs { 4 | skipNext: ({ 5 | tracks, 6 | currentTrack, 7 | setCurrentTrack, 8 | audioElem 9 | }: skipNextArgs) => Promise 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/SettingsPage/config/themesOptions.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next' 2 | 3 | import { Theme } from 'entities/App/types' 4 | 5 | export const themesOptions = () => [ 6 | { 7 | label: t('SettingsPage.light'), 8 | value: Theme.light 9 | }, 10 | { label: t('SettingsPage.dark'), value: Theme.dark } 11 | ] 12 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/AutoComplete/AutoComplete.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 25%; 3 | } 4 | 5 | @media screen and (max-width: 479px) { 6 | .tags { 7 | display: none; 8 | } 9 | .country { 10 | display: none; 11 | } 12 | .artist { 13 | display: none; 14 | } 15 | .container { 16 | width: 100%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useDebounceOnMount/types.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, SetStateAction } from 'react' 2 | 3 | export type useDebounceOnMountArgs = { 4 | audioElem: MutableRefObject 5 | setIsPlaying: (value: SetStateAction) => void 6 | setVolume: (value: React.SetStateAction) => void 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | 3 | import { defineConfig } from 'vite' 4 | 5 | import viteTsconfigPaths from 'vite-tsconfig-paths' 6 | 7 | export default defineConfig({ 8 | base: '', 9 | plugins: [react(), viteTsconfigPaths()], 10 | server: { 11 | port: 5173, 12 | open: true 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/shared/Redux/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type TypedUseSelectorHook, 3 | useDispatch, 4 | useSelector 5 | } from 'react-redux' 6 | 7 | import type { AppDispatch, RootState } from './store' 8 | 9 | export const useAppDispatch: () => AppDispatch = useDispatch 10 | export const useAppSelector: TypedUseSelectorHook = useSelector 11 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/onPlaying/types.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, SetStateAction } from 'react' 2 | 3 | import { Track } from 'entities/Track/types' 4 | 5 | export type onPlayingArgs = { 6 | audioElem: MutableRefObject 7 | currentTrack: Track 8 | setCurrentTrack: (value: SetStateAction) => void 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/FavoritesPage/FavoritesPage.module.scss: -------------------------------------------------------------------------------- 1 | .favoritesPage { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 20px; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | @media screen and (max-width: 479px) { 11 | .favoritesPage { 12 | margin-top: 5em; 13 | margin-left: 3em; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/SettingsPage/config/langsOptions.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'i18next' 2 | 3 | import { Language } from 'entities/App/types' 4 | 5 | export const langsOptions = () => [ 6 | { 7 | label: t('SettingsPage.english'), 8 | value: Language.en 9 | }, 10 | { 11 | label: t('SettingsPage.russian'), 12 | value: Language.ru 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/ResetPasswordForm/types.ts: -------------------------------------------------------------------------------- 1 | import { LoginForm } from '../LoginForm/types' 2 | 3 | export type ResetPasswordFormProps = { 4 | isVisible: boolean 5 | setIsVisible: (value: boolean) => void 6 | } 7 | 8 | export interface ResetPasswordForm extends Pick { 9 | repeatPassword: string 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/UI/Player/handlers/downloadHandler/downloadHandler.ts: -------------------------------------------------------------------------------- 1 | import { Track } from 'entities/Track/types' 2 | 3 | import { downloadResource } from 'shared/utils/downloadResource' 4 | 5 | export function downloadHandler(currentTrack: Track) { 6 | downloadResource( 7 | currentTrack.url, 8 | `${currentTrack.title} ${currentTrack.subTitle}.mp3` 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/stopDragingProgress/stopDragingProgress.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject, SetStateAction } from 'react' 2 | 3 | export function stopDragingProgress( 4 | setIsDragingProgress: Dispatch>, 5 | audioElem: MutableRefObject 6 | ) { 7 | setIsDragingProgress(false) 8 | audioElem?.current?.play() 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/SigUpForm/types.ts: -------------------------------------------------------------------------------- 1 | import { LoginForm } from '../LoginForm/types' 2 | import { ResetPasswordForm } from '../ResetPasswordForm/types' 3 | 4 | export type SignUpFormProps = { 5 | isVisible: boolean 6 | setIsVisible: (value: boolean) => void 7 | } 8 | 9 | export interface SignUpForm extends LoginForm, ResetPasswordForm { 10 | name: string 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/UI/Navbar/Navbar.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../constants/colors.scss'; 2 | 3 | .themeSwitcherContainer { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | gap: 5px; 8 | } 9 | 10 | .navbar { 11 | box-shadow: 0 0 5px $primary-grey; 12 | } 13 | 14 | @media screen and (max-width: 479px) { 15 | .navbar { 16 | display: none; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/HOC/PrivateRouter/PrivateRouter.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Outlet } from 'react-router-dom' 2 | 3 | import { IProps } from './type' 4 | 5 | export default function PrivateRouter({ 6 | children, 7 | redirectPath = '/auth/login', 8 | isAllowed 9 | }: IProps): JSX.Element { 10 | if (!isAllowed) return 11 | return children || 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/AutoComplete/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react' 2 | 3 | export interface AutoCompleteProps { 4 | field: { 5 | label: string 6 | name: string 7 | value: string 8 | onChange: Dispatch> 9 | path: string 10 | options: string[] 11 | setOptions: (options: string[]) => void 12 | required?: boolean 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/constants/colors.scss: -------------------------------------------------------------------------------- 1 | $primary-grey: #434544; 2 | $primary-black: #2a2630; 3 | $primary-white: #f3f3f3; 4 | $primary-green: #5ee9bf; 5 | $primary-blue: #2f69ff; 6 | $primary-violet: #6360ff; 7 | $primary-pink: #a16ae8; 8 | 9 | $minor-grey: #bdbebe; 10 | $minor-violet: #b19ff9; 11 | $minor-pink: #db55d4; 12 | $minor-dark-pink: #b537b5; 13 | $minor-blue: #2105d0; 14 | $minor-red: #bb0310; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | jspm_packages/ 4 | 5 | # Distribution files 6 | dist/ 7 | build/ 8 | 9 | # Environment files 10 | .env 11 | .env.* 12 | 13 | # Log files 14 | logs/ 15 | *.log 16 | 17 | # IDE files 18 | .vscode/ 19 | .idea/ 20 | 21 | # Mac files 22 | .DS_Store 23 | public/.DS_Store 24 | 25 | # Compiled output 26 | *.min.js 27 | *.min.css 28 | 29 | # Other 30 | Thumbs.db 31 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/SearchForm/type.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent } from 'react' 2 | 3 | import { IBlockButtonProps } from 'nirvana-uikit/dist/ui/Buttons/BlockButtons/BlockButton/types' 4 | 5 | import { AutoCompleteProps } from '../../Inputs/AutoComplete/types' 6 | 7 | export type FormProps = { 8 | fields: AutoCompleteProps['field'][] 9 | buttons: IBlockButtonProps[] 10 | onSubmit: (e: FormEvent) => void 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/skipPrevious/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject, SetStateAction } from 'react' 2 | 3 | import { Track } from 'entities/Track/types' 4 | 5 | export interface skipPreviousArgs { 6 | tracks: Track[] 7 | currentTrack: Track 8 | setIsPlaying: Dispatch> 9 | setCurrentTrack: Dispatch> 10 | audioElem: MutableRefObject 11 | } 12 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 5173; 3 | server_name nirvana-music; 4 | root /usr/share/nginx/html; 5 | 6 | ssl_certificate /etc/nginx/ssl/fullchain.pem; 7 | ssl_certificate_key /etc/nginx/ssl/privkey.pem; 8 | 9 | ssl_protocols TLSv1.2 TLSv1.3; 10 | ssl_prefer_server_ciphers off; 11 | 12 | index index.html; 13 | 14 | location / { 15 | try_files $uri $uri/ /index.html; 16 | } 17 | } -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import { initReactI18next } from 'react-i18next' 2 | 3 | import i18n from 'i18next' 4 | import LanguageDetector from 'i18next-browser-languagedetector' 5 | import Backend from 'i18next-http-backend' 6 | 7 | i18n.use(Backend) 8 | .use(LanguageDetector) 9 | .use(initReactI18next) 10 | .init({ 11 | fallbackLng: 'en', 12 | interpolation: { 13 | escapeValue: false 14 | } 15 | }) 16 | 17 | export default i18n 18 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/usePlayOnMount/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject, SetStateAction } from 'react' 2 | 3 | import { Track } from 'entities/Track/types' 4 | 5 | export type usePlayOnMountArgs = { 6 | tracks: Track[] 7 | setCurrentTrack: Dispatch> 8 | position: number 9 | audioElem: MutableRefObject 10 | setIsPlaying: (value: SetStateAction) => void 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/onPlaying/onPlaying.ts: -------------------------------------------------------------------------------- 1 | import { onPlayingArgs } from './types' 2 | 3 | export function onPlaying({ 4 | audioElem, 5 | setCurrentTrack, 6 | currentTrack 7 | }: onPlayingArgs) { 8 | const duration = audioElem?.current?.duration 9 | const currentTime = audioElem?.current?.currentTime 10 | setCurrentTrack({ 11 | ...currentTrack, 12 | progress: (currentTime / duration) * 100, 13 | length: duration 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/FilesUploadIForm/types.ts: -------------------------------------------------------------------------------- 1 | import { type FormEvent } from 'react' 2 | 3 | import { type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit' 4 | 5 | import { type RootState } from 'shared/Redux/store' 6 | 7 | export interface onSumbitArgs { 8 | e: FormEvent 9 | dispatch: ThunkDispatch 10 | trackName: string 11 | track?: File 12 | img?: File 13 | artist: string 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/UI/Player/handlers/likeHandler/types.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction } from 'redux' 2 | import { ThunkDispatch } from 'redux-thunk' 3 | 4 | import { Track } from 'entities/Track/types' 5 | import { ActiveType } from 'entities/User/types' 6 | 7 | import { RootState } from 'shared/Redux/store' 8 | 9 | export type likeHandlerArgs = { 10 | currentTrack: Track 11 | dispatch: ThunkDispatch 12 | user: ActiveType 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/hooks/useCheckUser/useCheckUser.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | import { checkUserThunk } from 'entities/User/thunk' 4 | 5 | import { useAppDispatch, useAppSelector } from 'shared/Redux/hooks' 6 | 7 | export function useCheckUser() { 8 | const dispatch = useAppDispatch() 9 | useLayoutEffect(() => { 10 | dispatch(checkUserThunk()) 11 | }, []) 12 | 13 | const user = useAppSelector(state => state.user) 14 | return user 15 | } 16 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | import App from './app/App' 5 | import './i18n' 6 | import '@fontsource/roboto/300.css' 7 | import '@fontsource/roboto/400.css' 8 | import '@fontsource/roboto/500.css' 9 | import '@fontsource/roboto/700.css' 10 | 11 | import '../public/styles/style.css' 12 | 13 | const rootElement = document.getElementById('root') 14 | const root = createRoot(rootElement!) 15 | 16 | root.render() 17 | -------------------------------------------------------------------------------- /src/entities/Radios/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | import { RadiosState } from './types' 4 | 5 | const initialState: RadiosState = { 6 | radios: [] 7 | } 8 | 9 | export const radiosSlice = createSlice({ 10 | name: 'radio', 11 | initialState, 12 | reducers: { 13 | setRadio: (state, action) => { 14 | state.radios = action.payload 15 | } 16 | } 17 | }) 18 | 19 | export const { setRadio } = radiosSlice.actions 20 | 21 | export default radiosSlice.reducer 22 | -------------------------------------------------------------------------------- /src/entities/Track/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | import { TracksState } from './types' 4 | 5 | const initialState: TracksState = { 6 | tracks: [] 7 | } 8 | 9 | export const tracksSlice = createSlice({ 10 | name: 'track', 11 | initialState, 12 | reducers: { 13 | setTracks: (state, action) => { 14 | state.tracks = action.payload 15 | } 16 | } 17 | }) 18 | 19 | export const { setTracks } = tracksSlice.actions 20 | 21 | export default tracksSlice.reducer 22 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/SearchForm/SearchForm.module.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | display: flex; 3 | gap: 1em; 4 | align-items: center; 5 | margin-top: 1.5em; 6 | margin-bottom: 0.7em; 7 | width: 100%; 8 | justify-content: center; 9 | min-height: 3.5em; 10 | height: 5%; 11 | } 12 | 13 | .form button { 14 | width: 10%; 15 | height: 100%; 16 | } 17 | 18 | @media screen and (max-width: 479px) { 19 | .form button { 20 | width: 20%; 21 | height: 3em; 22 | } 23 | .form { 24 | width: 95%; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/hooks/useAutocomplete/useAutocomlete.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { request } from 'shared/Request/Requets' 4 | 5 | export function useAutocomplete(path: string) { 6 | const [options, setOptions] = useState([]) 7 | 8 | useEffect(() => { 9 | request 10 | .sendRequest({ 11 | url: path 12 | }) 13 | .then(res => setOptions(res.data)) 14 | .catch(e => console.error(e)) 15 | }, []) 16 | 17 | return { options, setOptions } 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/UI/Player/handlers/toggleVolumeControl/toggleVolumeControl.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, SetStateAction } from 'react' 2 | 3 | export function toggleVolumeControl( 4 | audioElem: MutableRefObject, 5 | setVolume: (value: SetStateAction) => void 6 | ) { 7 | if (!audioElem.current) return 8 | if (audioElem.current.volume > 0) { 9 | audioElem.current.volume = 0 10 | setVolume(0) 11 | } else { 12 | audioElem.current.volume = 1 13 | setVolume(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/icons/cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | -------------------------------------------------------------------------------- /src/shared/UI/Player/handlers/likeHandler/likeHandler.ts: -------------------------------------------------------------------------------- 1 | import { addLikeThunk, removeLikeThunk } from 'entities/CurTracks/thunk' 2 | 3 | import { likeHandlerArgs } from './types' 4 | 5 | export async function likeHandler({ 6 | currentTrack, 7 | dispatch, 8 | user 9 | }: likeHandlerArgs) { 10 | if (currentTrack.isLiked) { 11 | await dispatch( 12 | removeLikeThunk(currentTrack.id, user.id, currentTrack.type) 13 | ) 14 | } else { 15 | await dispatch(addLikeThunk(currentTrack, user.id, currentTrack.type)) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["DOM", "ESNext"], 7 | "jsx": "react-jsx", 8 | "noEmit": true, 9 | "isolatedModules": true, 10 | "strict": true, 11 | "baseUrl": "./src", 12 | "paths": { "@/*": ["./*/main"] }, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "resolveJsonModule": true 17 | }, 18 | "include": ["src/**/*", "public/locales"] 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/UI/AvatarButton/AvatarButton.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../constants/colors.scss'; 2 | 3 | .avatarContainer { 4 | position: fixed; 5 | top: 2vh; 6 | right: 2vh; 7 | border: none; 8 | border-radius: 50%; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | width: 4em; 13 | height: 4em; 14 | background-color: transparent; 15 | background-color: $primary-violet; 16 | transition: all 0.5s ease-out; 17 | z-index: 2; 18 | } 19 | 20 | .avatarContainer:hover { 21 | transform: scale(1.1); 22 | } 23 | -------------------------------------------------------------------------------- /src/entities/Promo/thunk.ts: -------------------------------------------------------------------------------- 1 | import { ThunkAction, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { setPromo } from './slice' 4 | 5 | import { RootState } from 'shared/Redux/store' 6 | import { request } from 'shared/Request/Requets' 7 | 8 | const URL = '/promo' 9 | 10 | export const getPromoThunk = 11 | (): ThunkAction => 12 | async dispatch => { 13 | const res = await request.sendRequest({ 14 | method: 'get', 15 | url: `${URL}` 16 | }) 17 | dispatch(setPromo(res?.data)) 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useDebounceOnMount/useDebounceOnPlay.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useDebounceOnMountArgs } from './types' 4 | 5 | export function useDebounceOnMount({ 6 | audioElem, 7 | setIsPlaying, 8 | setVolume 9 | }: useDebounceOnMountArgs) { 10 | useEffect(() => { 11 | const timeoutId = setTimeout(() => { 12 | audioElem.current.play() 13 | setIsPlaying(true) 14 | setVolume(audioElem.current.volume) 15 | }, 500) 16 | return () => { 17 | clearTimeout(timeoutId) 18 | } 19 | }, []) 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/skipNext/skipNext.ts: -------------------------------------------------------------------------------- 1 | import { skipNextArgs } from './types' 2 | 3 | export async function skipNext({ 4 | tracks, 5 | currentTrack, 6 | setCurrentTrack, 7 | audioElem, 8 | setIsPlaying 9 | }: skipNextArgs) { 10 | const index = tracks.findIndex(track => track.id === currentTrack.id) 11 | index === tracks.length - 1 12 | ? setCurrentTrack(tracks[0]) 13 | : setCurrentTrack(tracks[index + 1]) 14 | audioElem.current.currentTime = 0 15 | await audioElem?.current?.load() 16 | audioElem?.current?.play() 17 | setIsPlaying(true) 18 | } 19 | -------------------------------------------------------------------------------- /src/entities/Promo/slice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | import { Promo, PromoState } from './types' 5 | 6 | const initialState: PromoState = { 7 | promo: [] 8 | } 9 | 10 | export const promoSlice = createSlice({ 11 | name: 'promo', 12 | initialState, 13 | reducers: { 14 | setPromo: (state, action: PayloadAction) => { 15 | state.promo = action.payload 16 | } 17 | } 18 | }) 19 | 20 | export const { setPromo } = promoSlice.actions 21 | 22 | export default promoSlice.reducer 23 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useDebounceOnPlayPause/useDebounceOnPlayPause.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useDebounceOnPlayPauseArgs } from './types' 4 | 5 | export function useDebounceOnPlayPause({ 6 | audioElem, 7 | isPlaying 8 | }: useDebounceOnPlayPauseArgs) { 9 | useEffect(() => { 10 | const timeoutId = setTimeout(() => { 11 | if (isPlaying) { 12 | audioElem?.current?.play() 13 | } else { 14 | audioElem?.current?.pause() 15 | } 16 | }, 500) 17 | return () => { 18 | clearTimeout(timeoutId) 19 | } 20 | }, [isPlaying]) 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/skipPrevious/skipPrevious.ts: -------------------------------------------------------------------------------- 1 | import { skipPreviousArgs } from './types'; 2 | 3 | 4 | export async function skipPrevious({ 5 | tracks, 6 | currentTrack, 7 | setCurrentTrack, 8 | audioElem, 9 | setIsPlaying 10 | }: skipPreviousArgs) { 11 | const index = tracks.findIndex(track => track.id === currentTrack.id) 12 | index === 0 13 | ? setCurrentTrack(tracks[tracks.length - 1]) 14 | : setCurrentTrack(tracks[index - 1]) 15 | audioElem.current.currentTime = 0 16 | await audioElem?.current?.load() 17 | audioElem?.current?.play() 18 | setIsPlaying(true) 19 | } -------------------------------------------------------------------------------- /src/shared/utils/downloadResource.ts: -------------------------------------------------------------------------------- 1 | import { forceDownload } from './forceDownload' 2 | 3 | import { request } from 'shared/Request/Requets' 4 | 5 | export function downloadResource(url: string, filename: string) { 6 | request 7 | .sendRequest({ 8 | url, 9 | responseType: 'blob' 10 | }) 11 | .then(response => response?.data) 12 | .then(blob => { 13 | const blobUrl = URL.createObjectURL( 14 | new Blob([blob], { 15 | type: 'audio/mpeg' 16 | }) 17 | ) 18 | 19 | forceDownload(blobUrl, filename) 20 | }) 21 | .catch(e => console.error(e)) 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/hooks/useGetLoaders/useGetLoaders.ts: -------------------------------------------------------------------------------- 1 | import { useGetLoadersArgs } from './types' 2 | 3 | export function useGetLoaders({ 4 | offset, 5 | setOffset, 6 | dispatch, 7 | thunk, 8 | user 9 | }: useGetLoadersArgs) { 10 | const loadPrev = () => { 11 | if (offset >= 5) { 12 | setOffset(prev => prev - 5) 13 | dispatch(thunk(offset, user.id)) 14 | } else { 15 | dispatch(thunk(0, user.id)) 16 | } 17 | } 18 | 19 | const loadNext = () => { 20 | setOffset(prev => prev + 5) 21 | dispatch(thunk(offset, user.id)) 22 | } 23 | 24 | return { loadNext, loadPrev } 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/usePlayOnMount/usePlayOnMount.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | import { usePlayOnMountArgs } from './types' 4 | 5 | export function usePlayOnMount({ 6 | tracks, 7 | setCurrentTrack, 8 | position, 9 | audioElem, 10 | setIsPlaying 11 | }: usePlayOnMountArgs) { 12 | useLayoutEffect(() => { 13 | setCurrentTrack(tracks[position]) 14 | const timeoutId = setTimeout(() => { 15 | audioElem?.current?.play() 16 | setIsPlaying(true) 17 | }, 500) 18 | return () => { 19 | clearTimeout(timeoutId) 20 | } 21 | }, [tracks, position]) 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/UI/Player/hooks/useSkipNext/useSkipNext.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useSkipNextArgs } from './types' 4 | 5 | export function useSkipNext({ 6 | audioElem, 7 | currentTrack, 8 | skipNext, 9 | setCurrentTrack, 10 | tracks, 11 | setIsPlaying 12 | }: useSkipNextArgs) { 13 | useEffect(() => { 14 | if (audioElem?.current?.currentTime === currentTrack?.length) { 15 | skipNext({ 16 | audioElem, 17 | setCurrentTrack, 18 | tracks, 19 | currentTrack, 20 | setIsPlaying 21 | }) 22 | } 23 | }, [audioElem?.current?.currentTime]) 24 | } 25 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore node_modules directory, which can be large and is usually rebuilt inside the container 2 | node_modules/ 3 | 4 | # Ignore log files 5 | *.log 6 | 7 | # Ignore Git-related files and directories 8 | .git 9 | .gitignore 10 | 11 | # Ignore temporary files or build artifacts that are not needed in the final image 12 | tmp/ 13 | ssl/ 14 | build/ 15 | dist/ 16 | 17 | # Ignore editor/IDE specific files 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | *~ 22 | 23 | # Ignore Docker-related files that are not part of the application code 24 | Dockerfile 25 | docker-compose.yml 26 | .dockerignore -------------------------------------------------------------------------------- /src/shared/utils/turnOnPlayMode.ts: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { setIsPlayMode } from 'entities/App/slice' 4 | import { setCurTracks, setPosition } from 'entities/CurTracks/slice' 5 | import { Track } from 'entities/Track/types' 6 | 7 | import { RootState } from 'shared/Redux/store' 8 | 9 | export const turnOnPlayMode = ( 10 | i: number, 11 | tracks: Track[], 12 | dispatch: ThunkDispatch 13 | ) => { 14 | dispatch(setPosition(i)) 15 | dispatch(setCurTracks(tracks)) 16 | dispatch(setIsPlayMode(true)) 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/hooks/useGetLoaders/types.ts: -------------------------------------------------------------------------------- 1 | import { ThunkAction, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { ActiveType } from '../../../entities/User/types' 4 | 5 | import { RootState } from '../../Redux/store' 6 | 7 | type ThunkResult = ThunkAction 8 | 9 | export type useGetLoadersArgs = { 10 | offset: number 11 | setOffset: React.Dispatch> 12 | dispatch: ThunkDispatch 13 | thunk: (offset: number, userId: string) => ThunkResult 14 | user: ActiveType 15 | } 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "semi": false, 7 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 8 | "trailingComma": "none", 9 | "importOrder": [ 10 | "react", 11 | "redux", 12 | "nirvana-uikit", 13 | "", 14 | "pages/", 15 | "entities/", 16 | "types", 17 | "shared/", 18 | "config", 19 | "utils/", 20 | "hooks/", 21 | ".svg", 22 | "^../(.*)$", 23 | "(?=./*.module.scss)" 24 | ], 25 | "importOrderSeparation": true, 26 | "importOrderParserPlugins": ["jsx", "typescript"] 27 | } 28 | -------------------------------------------------------------------------------- /src/entities/Track/types.ts: -------------------------------------------------------------------------------- 1 | export interface Track { 2 | id: string 3 | title: string 4 | url: string 5 | subTitle: string 6 | img: string 7 | isLiked: boolean 8 | type: TrackType 9 | progress: number 10 | length: number 11 | } 12 | 13 | export enum TrackType { 14 | radio = 'radio', 15 | track = 'track' 16 | } 17 | 18 | export interface TracksState { 19 | tracks: Track[] 20 | } 21 | 22 | export interface UploadTrackFrom { 23 | trackName: string 24 | artist: string 25 | cover: File 26 | track: File 27 | } 28 | 29 | export interface SearchTrackForm { 30 | trackTitle: string 31 | artist: string 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/Select/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionCreatorWithPayload, 3 | ThunkDispatch, 4 | UnknownAction 5 | } from '@reduxjs/toolkit' 6 | 7 | import { Theme } from '../../../../entities/App/types' 8 | 9 | import { RootState } from 'shared/Redux/store' 10 | 11 | export interface SelectProps { 12 | label: string 13 | options: Option[] 14 | value: string 15 | onChange: 16 | | ActionCreatorWithPayload 17 | | ((value: string) => void) 18 | dispatch?: ThunkDispatch 19 | } 20 | 21 | interface Option { 22 | label: string 23 | value: string 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/UI/PromoSlider/PromoSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Carousel } from 'antd' 2 | 3 | import { PromoSliderProps } from './types' 4 | 5 | import styles from './PromoSlider.module.scss' 6 | 7 | function PromoSlider({ promos }: PromoSliderProps) { 8 | return ( 9 | 10 | {promos?.map(promo => ( 11 | promo 19 | ))} 20 | 21 | ) 22 | } 23 | 24 | export default PromoSlider 25 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/FilesUploadIForm/onSubmit.ts: -------------------------------------------------------------------------------- 1 | import { uploadTrackThunk } from 'entities/Track/thunk' 2 | import { type UploadTrackFrom } from 'entities/Track/types' 3 | 4 | import { type onSumbitArgs } from './types' 5 | 6 | export function onSubmit({ 7 | e, 8 | dispatch, 9 | trackName, 10 | track, 11 | img, 12 | artist 13 | }: onSumbitArgs) { 14 | e.preventDefault() 15 | 16 | const formData = new FormData() 17 | 18 | formData.append('cover', img!) 19 | 20 | formData.append('track', track!) 21 | 22 | formData.append('trackName', trackName) 23 | formData.append('artist', artist) 24 | dispatch(uploadTrackThunk(formData as unknown as UploadTrackFrom)) 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/UI/Player/handlers/shareHandler/shareHandler.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction } from 'redux' 2 | import { ThunkDispatch } from 'redux-thunk' 3 | 4 | import { t } from 'i18next' 5 | 6 | import { setNotification } from 'entities/Notification/slice' 7 | import { Severity } from 'entities/Notification/types' 8 | 9 | import { RootState } from 'shared/Redux/store' 10 | 11 | export function shareHandler( 12 | dispatch: ThunkDispatch, 13 | title: string 14 | ) { 15 | navigator.clipboard.writeText(title + ' ' + URL) 16 | dispatch( 17 | setNotification({ 18 | message: t('Alert.linkCopied'), 19 | severity: Severity.success 20 | }) 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/entities/User/slice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | import { UserStatus, UserType } from './types' 5 | 6 | const initialState: UserType = { 7 | status: UserStatus.fetching 8 | } 9 | 10 | export const userSlice = createSlice({ 11 | name: 'user', 12 | initialState, 13 | reducers: { 14 | // @ts-ignore 15 | setUser: (state, action: PayloadAction) => { 16 | return action.payload 17 | }, 18 | // @ts-ignore 19 | logoutUser: () => ({ 20 | status: UserStatus.guest 21 | }) 22 | } 23 | }) 24 | 25 | export const { setUser, logoutUser } = userSlice.actions 26 | 27 | export default userSlice.reducer 28 | -------------------------------------------------------------------------------- /.github/workflows/github-actions-demo.yml: -------------------------------------------------------------------------------- 1 | name: onPush 2 | run-name: Actions on push 3 | on: [push] 4 | jobs: 5 | init: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [20.x] 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Staring Node.js ${{matrix.node-version}} 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: ${{matrix.node-version}} 16 | - name: install modules 17 | run: npm install 18 | - name: testing code 19 | run: npm run test 20 | - name: build project 21 | run: npm run build 22 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/checkVolume/checkVolume.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, MutableRefObject } from 'react' 2 | 3 | export const checkVolume = ( 4 | e: React.MouseEvent, 5 | isDragingVolume: boolean, 6 | volumeRef: MutableRefObject, 7 | audioElem: MutableRefObject, 8 | setVolume: Dispatch> 9 | ) => { 10 | if (!isDragingVolume) return 11 | const width = volumeRef?.current?.clientWidth 12 | ? volumeRef?.current?.clientWidth 13 | : 0 14 | const offset = e.nativeEvent?.offsetX 15 | const divProgress = (offset / width) * 100 16 | const newVolume = divProgress / 100 17 | audioElem.current.volume = newVolume 18 | setVolume(newVolume) 19 | } 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | 3 | import { resolve } from 'path' 4 | 5 | import { defineConfig } from 'vitest/config' 6 | 7 | export default defineConfig({ 8 | plugins: [react()], 9 | test: { 10 | globals: true, 11 | environment: 'jsdom', 12 | setupFiles: ['./src/test/setup.ts'], 13 | define: { 14 | 'process.env.NODE_ENV': '"test"' 15 | } 16 | }, 17 | resolve: { 18 | alias: { 19 | '@': resolve(__dirname, './src'), 20 | shared: resolve(__dirname, './src/shared'), 21 | entities: resolve(__dirname, './src/entities'), 22 | app: resolve(__dirname, './src/app'), 23 | pages: resolve(__dirname, './src/pages'), 24 | widgets: resolve(__dirname, './src/widgets') 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /src/entities/User/types.ts: -------------------------------------------------------------------------------- 1 | export type UserFromBackend = { 2 | id: string 3 | email: string 4 | nickname: string 5 | confirmed: boolean 6 | isAdmin: boolean 7 | } 8 | 9 | export type ActiveType = UserFromBackend & { 10 | status: UserStatus.active 11 | } 12 | 13 | export type FetchingUserType = { 14 | status: UserStatus.fetching 15 | } 16 | 17 | export type NonActiveType = UserFromBackend & { 18 | status: UserStatus.nonActive 19 | } 20 | 21 | export type GuestType = { 22 | status: UserStatus.guest 23 | } 24 | 25 | export enum UserStatus { 26 | guest = 'guest', 27 | nonActive = 'non-active', 28 | fetching = 'fetching', 29 | active = 'active' 30 | } 31 | 32 | export type UserType = ActiveType | GuestType | FetchingUserType | NonActiveType 33 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { vi } from 'vitest' 3 | 4 | // Мокаем i18next 5 | vi.mock('i18next', () => ({ 6 | t: (key: string) => key 7 | })) 8 | 9 | // Мокаем react-i18next 10 | vi.mock('react-i18next', () => ({ 11 | useTranslation: () => ({ 12 | t: (key: string) => key, 13 | i18n: { 14 | changeLanguage: vi.fn() 15 | } 16 | }) 17 | })) 18 | 19 | // Мокаем Redux store 20 | vi.mock('shared/Redux/store', () => ({ 21 | store: { 22 | getState: vi.fn(), 23 | dispatch: vi.fn(), 24 | subscribe: vi.fn() 25 | } 26 | })) 27 | 28 | // Мокаем axios 29 | vi.mock('axios', () => ({ 30 | default: { 31 | create: vi.fn(() => ({ 32 | get: vi.fn(), 33 | post: vi.fn(), 34 | put: vi.fn(), 35 | delete: vi.fn() 36 | })) 37 | } 38 | })) 39 | -------------------------------------------------------------------------------- /src/shared/HOC/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo, ReactNode } from 'react' 2 | 3 | import ErrorPage from 'pages/ErrorPage/ErrorPage' 4 | 5 | import { IProps, IState } from './types' 6 | 7 | export class ErrorBoundary extends Component { 8 | constructor(props: IProps) { 9 | super(props) 10 | this.state = { hasError: false, error: null } 11 | } 12 | 13 | static getDerivedStateFromError(error: Error) { 14 | return { hasError: true, error: error } 15 | } 16 | 17 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 18 | console.error({ Error: error, ErrorInfo: errorInfo }) 19 | } 20 | 21 | render(): ReactNode { 22 | if (this.state.hasError) { 23 | return 24 | } 25 | return this.props.children 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/shared/Request/Requets.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosResponse } from 'axios' 2 | 3 | import { type IRequestParams } from './types' 4 | 5 | const NewInstanse = axios.create({ 6 | //@ts-ignore 7 | baseURL: import.meta.env.VITE_BASE_URL, 8 | withCredentials: true 9 | }) 10 | 11 | class Request { 12 | async sendRequest({ 13 | method = 'get', 14 | url, 15 | data, 16 | responseType = 'json' 17 | }: IRequestParams): Promise { 18 | const requestOptions = { 19 | method, 20 | url, 21 | data, 22 | responseType 23 | } 24 | //@ts-ignore 25 | return await NewInstanse(requestOptions) 26 | .then(function (response: AxiosResponse) { 27 | return response 28 | }) 29 | .catch(error => error.response) 30 | } 31 | } 32 | 33 | export const request = new Request() 34 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import { Provider } from 'react-redux' 3 | import { BrowserRouter } from 'react-router-dom' 4 | 5 | import { Loader } from 'nirvana-uikit' 6 | 7 | import MainRoutes from './routes/MainRoutes/MainRoutes' 8 | 9 | import { ErrorBoundary } from 'shared/HOC/ErrorBoundary/ErrorBoundary' 10 | import { store } from 'shared/Redux/store' 11 | import Cursor from 'shared/UI/Cursor/Cursor' 12 | 13 | function App(): JSX.Element { 14 | return ( 15 | <> 16 | 17 | 18 | }> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export default App 31 | -------------------------------------------------------------------------------- /src/pages/CodePage/CodePage.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useTranslation } from 'react-i18next' 3 | 4 | import { Typography } from 'nirvana-uikit' 5 | 6 | import { useAppSelector } from 'shared/Redux/hooks' 7 | import CodeForm from 'shared/UI/Forms/AuthForms/CodeForm/CodeForm' 8 | 9 | import styles from './CodePage.module.scss' 10 | 11 | export default function CodePage(): JSX.Element { 12 | const { theme } = useAppSelector(state => state.app) 13 | const { t } = useTranslation() 14 | return ( 15 |
16 |
21 | 22 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/UI/Cursor/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import AnimatedCursor from 'react-animated-cursor' 2 | 3 | function Cursor() { 4 | return ( 5 |
6 | 28 |
29 | ) 30 | } 31 | 32 | export default Cursor 33 | -------------------------------------------------------------------------------- /src/shared/utils/validateEmail.ts: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { t } from 'i18next' 4 | 5 | import { setIsOpen, setNotification } from 'entities/Notification/slice' 6 | import { Severity } from 'entities/Notification/types' 7 | 8 | import { RootState } from 'shared/Redux/store' 9 | 10 | export function validateEmail( 11 | email: string, 12 | dispatch: ThunkDispatch 13 | ): boolean { 14 | const regex = /^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/ 15 | 16 | if (!regex.test(email)) { 17 | dispatch( 18 | setNotification({ 19 | message: t('Alert.emailValidationUnsuccessfully'), 20 | severity: Severity.error 21 | }) 22 | ) 23 | dispatch(setIsOpen(true)) 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/UI/TrackSlider/handlers/shareHandler.ts: -------------------------------------------------------------------------------- 1 | import { type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { t } from 'i18next' 4 | 5 | import { setNotification } from 'entities/Notification/slice' 6 | import { Severity } from 'entities/Notification/types' 7 | 8 | import { type RootState } from 'shared/Redux/store' 9 | 10 | export function shareHandler( 11 | dispatch: ThunkDispatch 12 | ) { 13 | const URL = `${window.location.protocol}//${window.location.host}${window.location.pathname}` 14 | const title = 'Check out best free music streaming app. Dive in Nirvana' 15 | navigator.clipboard.writeText(title + ' ' + URL) 16 | dispatch( 17 | setNotification({ 18 | message: t('Alert.linkCopied'), 19 | severity: Severity.success 20 | }) 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/Error404/Error404.module.scss: -------------------------------------------------------------------------------- 1 | .error404 { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | background-size: 300% 300%; 8 | background-image: linear-gradient( 9 | 90deg, 10 | rgba(243, 243, 243, 1) 0%, 11 | rgba(94, 233, 191, 1) 19%, 12 | rgba(47, 105, 255, 1) 54%, 13 | rgba(99, 96, 255, 1) 82%, 14 | rgba(161, 106, 232, 1) 100% 15 | ); 16 | animation: AnimateBG 20s ease infinite; 17 | } 18 | 19 | .textContainer { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | flex-direction: column; 24 | gap: 10px; 25 | } 26 | 27 | @keyframes AnimateBG { 28 | 0% { 29 | background-position: 0% 50%; 30 | } 31 | 50% { 32 | background-position: 100% 50%; 33 | } 34 | 100% { 35 | background-position: 0% 50%; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/ErrorPage/ErrorPage.module.scss: -------------------------------------------------------------------------------- 1 | .errorPage { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | background-size: 300% 300%; 8 | background-image: linear-gradient( 9 | 90deg, 10 | rgba(243, 243, 243, 1) 0%, 11 | rgba(94, 233, 191, 1) 19%, 12 | rgba(47, 105, 255, 1) 54%, 13 | rgba(99, 96, 255, 1) 82%, 14 | rgba(161, 106, 232, 1) 100% 15 | ); 16 | animation: AnimateBG 20s ease infinite; 17 | } 18 | 19 | .textContainer { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | flex-direction: column; 24 | gap: 10px; 25 | } 26 | 27 | @keyframes AnimateBG { 28 | 0% { 29 | background-position: 0% 50%; 30 | } 31 | 50% { 32 | background-position: 100% 50%; 33 | } 34 | 100% { 35 | background-position: 0% 50%; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/UI/Player/utils/checkWidth/checkWidth.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, MutableRefObject } from 'react' 2 | 3 | import { Track } from 'entities/Track/types' 4 | 5 | export async function checkWidth( 6 | e: MouseEvent, 7 | isDragingProgress: boolean, 8 | audioElem: MutableRefObject, 9 | clickRef: MutableRefObject, 10 | currentTrack: Track 11 | ) { 12 | if (!isDragingProgress) return 13 | await audioElem?.current?.pause() 14 | const width = clickRef?.current?.clientWidth 15 | ? clickRef?.current?.clientWidth 16 | : 0 17 | const offset = e.nativeEvent?.offsetX 18 | const divProgress = (offset / width) * 100 19 | const newCurrentTime = (divProgress / 100) * currentTrack.length 20 | audioElem.current.currentTime = isFinite(newCurrentTime) 21 | ? newCurrentTime 22 | : 100 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/UI/TracksRow/TracksRow.module.scss: -------------------------------------------------------------------------------- 1 | .cardsContainer { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | justify-content: space-between; 6 | gap: 1em; 7 | } 8 | 9 | .cardsFlowContainer { 10 | margin-bottom: 1em; 11 | margin-right: 2em; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | width: 90%; 17 | } 18 | 19 | .header { 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | margin-bottom: 1em; 24 | width: 100%; 25 | } 26 | 27 | .buttonsContainer { 28 | display: flex; 29 | align-items: center; 30 | gap: 1em; 31 | margin-right: 1em; 32 | } 33 | 34 | @media screen and (max-width: 479px) { 35 | .emailContainer { 36 | width: 80%; 37 | } 38 | .cardsContainer { 39 | align-items: baseline; 40 | } 41 | .buttonsContainer { 42 | display: none; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/layout/MainLayout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | 3 | import { Loader } from 'nirvana-uikit' 4 | 5 | import { type MainLayoutProps } from './types' 6 | 7 | import { useAppSelector } from 'shared/Redux/hooks' 8 | import Toast from 'shared/UI/Toast/Toast' 9 | import useTheme from 'shared/hooks/useTheme/useTheme' 10 | 11 | import styles from './MainLayout.module.scss' 12 | 13 | function MainLayout({ user }: MainLayoutProps) { 14 | const notification = useAppSelector(state => state.notification) 15 | 16 | useTheme() 17 | 18 | return ( 19 |
20 | {user?.status === 'fetching' ? ( 21 | 22 | ) : ( 23 | <> 24 | {notification.message && ( 25 | 26 | )} 27 | 28 | 29 | )} 30 |
31 | ) 32 | } 33 | 34 | export default MainLayout 35 | -------------------------------------------------------------------------------- /src/shared/hooks/useTheme/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { Theme } from 'entities/App/types' 4 | 5 | import { useAppSelector } from 'shared/Redux/hooks' 6 | 7 | function useTheme() { 8 | const { theme } = useAppSelector(state => state.app) 9 | 10 | useEffect(() => { 11 | recolor(theme) 12 | }, []) 13 | 14 | useEffect(() => { 15 | recolor(theme) 16 | localStorage.setItem('theme', theme) 17 | }, [theme]) 18 | return theme === Theme.light ? Theme.dark : Theme.light 19 | } 20 | 21 | export default useTheme 22 | 23 | function recolor(theme: Theme) { 24 | const body = document.querySelector('body') 25 | if (!body) return 26 | if (theme === Theme.dark) { 27 | body.classList.remove(Theme.light) 28 | body.className += ` ${Theme.dark}` 29 | } else if (theme === Theme.light) { 30 | body.classList.remove(Theme.dark) 31 | body.className += ` ${Theme.light}` 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/TrackPage/handlers/searchHandler.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction } from 'redux' 2 | import { ThunkDispatch } from 'redux-thunk' 3 | 4 | import { getTracksThunk, searchTracksThunk } from 'entities/Track/thunk' 5 | import { ActiveType } from 'entities/User/types' 6 | 7 | import { RootState } from 'shared/Redux/store' 8 | 9 | export const searchHandler = ( 10 | e: React.FormEvent, 11 | dispatch: ThunkDispatch, 12 | user: ActiveType 13 | ) => { 14 | e.preventDefault() 15 | 16 | const form = e.currentTarget 17 | const formData = { 18 | trackTitle: form.trackTitle.value, 19 | artist: form.artist.value 20 | } 21 | if (!formData.trackTitle && !formData.artist) { 22 | dispatch(getTracksThunk(0, (user as unknown as ActiveType).id)) 23 | } else { 24 | dispatch( 25 | searchTracksThunk(formData, (user as unknown as ActiveType).id) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as builder 2 | WORKDIR /app 3 | 4 | # Копируем package.json и package-lock.json для лучшего кэширования 5 | COPY package*.json ./ 6 | 7 | # Устанавливаем зависимости 8 | RUN npm ci 9 | 10 | COPY .env .env 11 | COPY . . 12 | 13 | RUN npm run build 14 | 15 | FROM nginx:alpine as production 16 | 17 | # Устанавливаем openssl для создания сертификата 18 | RUN apk add --no-cache openssl 19 | 20 | # Создаем директорию для SSL сертификатов 21 | RUN mkdir -p /etc/nginx/ssl 22 | 23 | # Создаем самоподписанный SSL сертификат 24 | RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ 25 | -keyout /etc/nginx/ssl/privkey.pem \ 26 | -out /etc/nginx/ssl/fullchain.pem \ 27 | -subj "/C=RU/ST=Moscow/L=Moscow/O=Nirvana/OU=IT/CN=nirvana-music" 28 | 29 | COPY --from=builder /app/dist /usr/share/nginx/html 30 | COPY nginx.conf /etc/nginx/conf.d/default.conf 31 | 32 | EXPOSE 5173 33 | 34 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /src/app/layout/AZLayout/AZLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | 3 | import { Messenger } from 'widgets/Messenger/Messenger' 4 | 5 | import { ActiveType } from 'entities/User/types' 6 | 7 | import { useAppSelector } from 'shared/Redux/hooks' 8 | import AvatarButton from 'shared/UI/AvatarButton/AvatarButton' 9 | import Burger from 'shared/UI/Menu/Burger' 10 | import Navbar from 'shared/UI/Navbar/Navbar' 11 | import { Player } from 'shared/UI/Player/Player' 12 | 13 | import styles from './AZLayout.module.scss' 14 | 15 | export default function AZLayout() { 16 | const user = useAppSelector(state => state.user) 17 | const { isPlayMode } = useAppSelector(state => state.app) 18 | 19 | return ( 20 |
21 | 22 | 23 | 24 | 25 | 26 | {isPlayMode && } 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/entities/Notification/slice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | import { Notification } from './types' 5 | 6 | const initialState: Notification = { 7 | severity: '', 8 | message: '', 9 | isOpen: false 10 | } 11 | 12 | export const notificationSlice = createSlice({ 13 | name: 'notification', 14 | initialState, 15 | reducers: { 16 | setNotification: (state, action: PayloadAction) => 17 | action.payload, 18 | clearNotification: state => ({ 19 | message: '', 20 | severity: '', 21 | isOpen: false 22 | }), 23 | setIsOpen: (state, action: PayloadAction) => { 24 | const newState = { ...state } 25 | newState.isOpen = action.payload 26 | return newState 27 | } 28 | } 29 | }) 30 | 31 | export const { setNotification, clearNotification, setIsOpen } = 32 | notificationSlice.actions 33 | 34 | export default notificationSlice.reducer 35 | -------------------------------------------------------------------------------- /src/pages/EmailPage/EmailPage.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useTranslation } from 'react-i18next' 3 | 4 | import { Typography } from 'nirvana-uikit' 5 | 6 | import { useAppSelector } from 'shared/Redux/hooks' 7 | import EmailForm from 'shared/UI/Forms/AuthForms/EmailForm/EmailForm' 8 | 9 | import styles from './EmailPage.module.scss' 10 | 11 | export default function EmailPage(): JSX.Element { 12 | const { theme } = useAppSelector(state => state.app) 13 | const { t } = useTranslation() 14 | return ( 15 |
16 |
21 |
22 | 26 |
27 |
28 | 29 |
30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/Error404/Error404.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useTranslation } from 'react-i18next' 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | import { BlockButton, Typography } from 'nirvana-uikit' 6 | 7 | import styles from './Error404.module.scss' 8 | 9 | export default function Error404() { 10 | const navigate = useNavigate() 11 | const { t } = useTranslation() 12 | return ( 13 |
14 |
15 | 22 | 28 | navigate('/')} 31 | type="button" 32 | /> 33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/entities/App/slice.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadAction } from '@reduxjs/toolkit' 2 | import { createSlice } from '@reduxjs/toolkit' 3 | 4 | import { AppState, Theme } from './types' 5 | 6 | const currentTheme = window.matchMedia('(prefers-color-scheme: dark)').matches 7 | ? Theme.dark 8 | : Theme.light 9 | 10 | const savedTheme = localStorage.getItem('theme') as Theme 11 | 12 | const initialState: AppState = { 13 | theme: savedTheme || currentTheme || Theme.light, 14 | isPlayMode: false 15 | } 16 | 17 | export const appSlice = createSlice({ 18 | name: 'app', 19 | initialState, 20 | reducers: { 21 | changeTheme: (state, action: PayloadAction) => { 22 | state.theme = action.payload 23 | }, 24 | setIsPlayMode: ( 25 | state, 26 | action: PayloadAction 27 | ) => { 28 | state.isPlayMode = action.payload 29 | } 30 | } 31 | }) 32 | 33 | export const { changeTheme, setIsPlayMode } = appSlice.actions 34 | 35 | export default appSlice.reducer 36 | -------------------------------------------------------------------------------- /src/app/routes/NAZRoutes/NAZRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react' 2 | import { Route, Routes } from 'react-router-dom' 3 | 4 | const CodePage = lazy(() => import('../../../pages/CodePage/CodePage')) 5 | const EmailPage = lazy(() => import('../../../pages/EmailPage/EmailPage')) 6 | const SignupPage = lazy(() => import('../../../pages/SignupPage/SignupPage')) 7 | const ResetPasswordPage = lazy( 8 | () => import('../../../pages/ResetPasswordPage/ResetPasswordPage') 9 | ) 10 | const LoginPage = lazy(() => import('../../../pages/LoginPage/LoginPage')) 11 | 12 | function NAZRoutes() { 13 | return ( 14 | 15 | } /> 16 | } /> 17 | } /> 18 | } /> 19 | } 22 | /> 23 | 24 | ) 25 | } 26 | 27 | export default NAZRoutes 28 | -------------------------------------------------------------------------------- /src/shared/utils/__tests__/formatTime.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { formatTime } from '../formatTime' 4 | 5 | describe('formatTime', () => { 6 | it('должен форматировать время в минуты и секунды', () => { 7 | expect(formatTime(0)).toBe('0:00') 8 | expect(formatTime(30)).toBe('0:30') 9 | expect(formatTime(60)).toBe('1:00') 10 | expect(formatTime(90)).toBe('1:30') 11 | expect(formatTime(125)).toBe('2:05') 12 | expect(formatTime(3661)).toBe('61:01') 13 | }) 14 | 15 | it('должен правильно обрабатывать отрицательные числа', () => { 16 | expect(formatTime(-30)).toBe('-1:-30') 17 | expect(formatTime(-60)).toBe('-1:00') 18 | }) 19 | 20 | it('должен правильно обрабатывать десятичные числа', () => { 21 | expect(formatTime(30.7)).toBe('0:30') 22 | expect(formatTime(60.9)).toBe('1:00') 23 | }) 24 | 25 | it('должен правильно обрабатывать большие числа', () => { 26 | expect(formatTime(3600)).toBe('60:00') 27 | expect(formatTime(7200)).toBe('120:00') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/pages/RadioPage/handlers/searchHandler.ts: -------------------------------------------------------------------------------- 1 | import { UnknownAction } from 'redux' 2 | import { ThunkDispatch } from 'redux-thunk' 3 | 4 | import { getAllRadiosThunk, searchRadioThunk } from 'entities/Radios/thunk' 5 | import { SearchRadioForm } from 'entities/Radios/types' 6 | import { ActiveType } from 'entities/User/types' 7 | 8 | import { RootState } from 'shared/Redux/store' 9 | 10 | export const searchHandler = ( 11 | e: React.FormEvent, 12 | dispatch: ThunkDispatch, 13 | user: ActiveType 14 | ) => { 15 | e.preventDefault() 16 | 17 | const form = e.currentTarget 18 | const formData = { 19 | name: form.radio.value, 20 | tags: form.tags.value, 21 | country: form.country.value 22 | } 23 | if (!formData.name && !formData.tags && !formData.country) { 24 | dispatch(getAllRadiosThunk(0, (user as unknown as ActiveType).id)) 25 | } else { 26 | dispatch( 27 | searchRadioThunk( 28 | formData as SearchRadioForm, 29 | (user as unknown as ActiveType).id 30 | ) 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/shared/UI/AvatarButton/AvatarButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined' 4 | import Avatar from '@mui/material/Avatar' 5 | 6 | import { logoutThunk } from 'entities/User/thunk' 7 | 8 | import { AvatarProps } from './types' 9 | 10 | import { useAppDispatch } from 'shared/Redux/hooks' 11 | 12 | import styles from './AvatarButton.module.scss' 13 | 14 | function AvatarButton({ nickname }: AvatarProps) { 15 | const dispatch = useAppDispatch() 16 | const [isHovered, setIsHovered] = useState(false) 17 | return ( 18 | 32 | ) 33 | } 34 | 35 | export default AvatarButton 36 | -------------------------------------------------------------------------------- /src/entities/Radios/thunk.ts: -------------------------------------------------------------------------------- 1 | import { ThunkAction, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { setRadio } from './slice' 4 | 5 | import { SearchRadioForm } from './types' 6 | 7 | import { RootState } from 'shared/Redux/store' 8 | import { request } from 'shared/Request/Requets' 9 | 10 | const URL = '/radio' 11 | 12 | export const getAllRadiosThunk = 13 | ( 14 | offset: number, 15 | userId: string 16 | ): ThunkAction => 17 | async dispatch => { 18 | const res = await request.sendRequest({ 19 | method: 'post', 20 | url: `${URL}`, 21 | data: { offset, userId } 22 | }) 23 | dispatch(setRadio(res?.data)) 24 | } 25 | 26 | export const searchRadioThunk = 27 | ( 28 | formData: SearchRadioForm, 29 | userId: string 30 | ): ThunkAction => 31 | async dispatch => { 32 | const res = await request.sendRequest({ 33 | method: 'post', 34 | url: `${URL}/search`, 35 | data: { ...formData, userId } 36 | }) 37 | dispatch(setRadio(res?.data)) 38 | } 39 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/CodeForm/CodeForm.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useState } from 'react' 3 | import ReactCodeInput from 'react-code-input' 4 | import { useTranslation } from 'react-i18next' 5 | import { useNavigate } from 'react-router-dom' 6 | 7 | import { BlockButton } from 'nirvana-uikit' 8 | 9 | import { onSubmit } from './onSubmit' 10 | 11 | import { useAppDispatch } from 'shared/Redux/hooks' 12 | 13 | import styles from './CodeForm.module.scss' 14 | 15 | function CodeForm() { 16 | const dispatch = useAppDispatch() 17 | const navigate = useNavigate() 18 | const { t } = useTranslation() 19 | const [value, setValue] = useState('') 20 | return ( 21 |
22 | 29 | onSubmit(value, dispatch, navigate)} 33 | /> 34 |
35 | ) 36 | } 37 | 38 | export default CodeForm 39 | -------------------------------------------------------------------------------- /src/pages/ErrorPage/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | import { BlockButton, Typography } from 'nirvana-uikit' 6 | 7 | import styles from './ErrorPage.module.scss' 8 | 9 | function ErrorPage() { 10 | const [isHover, setIsHover] = useState(false) 11 | const { t } = useTranslation() 12 | return ( 13 |
14 |
15 | {isHover ? ( 16 | 17 | ) : ( 18 | 19 | )} 20 | 27 | location.reload()} 30 | onMouseOver={() => setIsHover(true)} 31 | onMouseLeave={() => setIsHover(false)} 32 | /> 33 |
34 |
35 | ) 36 | } 37 | 38 | export default ErrorPage 39 | -------------------------------------------------------------------------------- /src/shared/UI/Menu/Burger.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../constants/colors.scss'; 2 | 3 | .burger { 4 | position: fixed; 5 | display: none; 6 | } 7 | 8 | .burger button { 9 | width: 100%; 10 | } 11 | 12 | .burger:hover { 13 | transform: scale(1.1); 14 | } 15 | 16 | .burgerItems { 17 | position: fixed; 18 | margin-top: 6em; 19 | margin-left: 1em; 20 | display: flex; 21 | justify-content: space-between; 22 | flex-direction: column; 23 | gap: 10; 24 | border-radius: 4px; 25 | overflow: hidden; 26 | width: 'fit-content'; 27 | background-color: $primary-white; 28 | } 29 | 30 | .dark { 31 | background-color: $primary-black; 32 | } 33 | 34 | .dark button { 35 | color: $primary-white; 36 | } 37 | 38 | @media screen and (max-width: 479px) { 39 | .burger { 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | z-index: 2; 44 | top: 2em; 45 | left: 2em; 46 | height: 2em; 47 | width: 2em; 48 | } 49 | .burger button { 50 | opacity: 1; 51 | border-radius: 0; 52 | width: 100%; 53 | height: 4em; 54 | background-color: transparent; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "overrides": [], 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": ["react", "prettier"], 18 | "rules": { 19 | "react/react-in-jsx-scope": "off", 20 | "@typescript-eslint/strict-boolean-expressions": "off", 21 | "@typescript-eslint/no-misused-promises": "off", 22 | "@typescript-eslint/no-floating-promises": "off", 23 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 24 | "@typescript-eslint/no-non-null-assertion": "off", 25 | "@typescript-eslint/explicit-function-return-type": "off", 26 | "@typescript-eslint/prefer-ts-expect-error": "off", 27 | "@typescript-eslint/ban-ts-comment": "off", 28 | "@typescript-eslint/unbound-method": "off", 29 | "@typescript-eslint/await-thenable": "off", 30 | "@typescript-eslint/consistent-type-assertions": "off" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/FilesUploadIForm/FilesUploadForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../constants/colors.scss'; 2 | 3 | .formContainer { 4 | width: 100%; 5 | height: 25%; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | gap: 15px; 10 | } 11 | 12 | .formContainer form { 13 | width: 100%; 14 | height: 25%; 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-around; 18 | gap: 20px; 19 | border: solid 1px $primary-grey; 20 | padding: 1em; 21 | border-radius: 4px; 22 | } 23 | 24 | .formContainer span { 25 | text-transform: none; 26 | } 27 | 28 | .formContainer [type='file']::file-selector-button { 29 | border: none; 30 | background-color: $primary-grey; 31 | opacity: 0.5; 32 | border-radius: 0.4em; 33 | color: $primary-white; 34 | transition: 1s; 35 | padding: 0.7em; 36 | } 37 | 38 | .formContainer input[type='file']::file-selector-button:hover { 39 | background-color: $primary-violet; 40 | color: $primary-white; 41 | opacity: 1; 42 | } 43 | 44 | @media screen and (max-width: 479px) { 45 | .formContainer { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/SearchForm/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | 3 | import { BlockButton } from 'nirvana-uikit' 4 | 5 | import { FormProps } from './type' 6 | 7 | import { AutoComplete } from 'shared/UI/Inputs/AutoComplete/AutoComplete' 8 | 9 | import styles from './SearchForm.module.scss' 10 | 11 | export const SearchForm = memo(function SearchForm({ 12 | fields, 13 | buttons, 14 | onSubmit 15 | }: FormProps) { 16 | let i = fields.length + buttons.length + 1 17 | let j = buttons.length + 1 18 | return ( 19 |
20 | {fields.map(field => { 21 | if (i > buttons.length) { 22 | i-- 23 | return ( 24 | 28 | ) 29 | } 30 | })} 31 | {buttons.map(button => { 32 | if (j > buttons.length) { 33 | j-- 34 | return ( 35 | 40 | ) 41 | } 42 | })} 43 | 44 | ) 45 | }) 46 | -------------------------------------------------------------------------------- /src/entities/Favorite/thunk.ts: -------------------------------------------------------------------------------- 1 | import { ThunkAction, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { setFavoriteRadios, setFavoriteTracks } from './slice' 4 | 5 | import { RootState } from 'shared/Redux/store' 6 | import { request } from 'shared/Request/Requets' 7 | 8 | const URL = '/favorite' 9 | 10 | export const getFavoriteTracksThunk = 11 | ( 12 | offset: number, 13 | userId: string 14 | ): ThunkAction => 15 | async dispatch => { 16 | const res = await request.sendRequest({ 17 | method: 'post', 18 | url: `${URL}/all`, 19 | data: { offset: offset, userId: userId, type: 'track' } 20 | }) 21 | dispatch(setFavoriteTracks(res?.data)) 22 | } 23 | 24 | export const getFavoriteRadiosThunk = 25 | ( 26 | offset: number, 27 | userId: string 28 | ): ThunkAction => 29 | async dispatch => { 30 | const res = await request.sendRequest({ 31 | method: 'post', 32 | url: `${URL}/all`, 33 | data: { offset: offset, userId: userId, type: 'radio' } 34 | }) 35 | dispatch(setFavoriteRadios(res?.data)) 36 | } 37 | -------------------------------------------------------------------------------- /src/shared/utils/validatePassword.ts: -------------------------------------------------------------------------------- 1 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 2 | 3 | import { t } from 'i18next' 4 | 5 | import { setIsOpen, setNotification } from 'entities/Notification/slice' 6 | import { Severity } from 'entities/Notification/types' 7 | 8 | import { RootState } from 'shared/Redux/store' 9 | 10 | export function validatePassword( 11 | password: string, 12 | repeatPassword: string, 13 | dispatch: ThunkDispatch 14 | ): boolean { 15 | const regex = 16 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ 17 | 18 | if (!regex.test(password)) { 19 | dispatch( 20 | setNotification({ 21 | message: t('Alert.passwordValidationError'), 22 | severity: Severity.error 23 | }) 24 | ) 25 | dispatch(setIsOpen(true)) 26 | return false 27 | } 28 | if (password !== repeatPassword) { 29 | dispatch( 30 | setNotification({ 31 | message: t('Alert.passwordMatchError'), 32 | severity: Severity.error 33 | }) 34 | ) 35 | dispatch(setIsOpen(true)) 36 | return false 37 | } 38 | return true 39 | } 40 | -------------------------------------------------------------------------------- /src/entities/CurTracks/slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | import { CurTracks } from './types' 4 | 5 | const initialState: CurTracks = { 6 | curTracks: [], 7 | position: 0 8 | } 9 | 10 | export const curTracksSlice = createSlice({ 11 | name: 'curTracks', 12 | initialState, 13 | reducers: { 14 | setCurTracks: (state, action) => { 15 | state.curTracks = action.payload 16 | }, 17 | setPosition: (state, action) => { 18 | state.position = action.payload 19 | }, 20 | addLikeToCurTrack: (state, action) => { 21 | state.curTracks = state.curTracks.map(track => { 22 | if (action.payload === track.id) { 23 | track.isLiked = true 24 | } 25 | return track 26 | }) 27 | }, 28 | removeLikeFromCurTrack: (state, action) => { 29 | state.curTracks = state.curTracks.map(track => { 30 | if (action.payload === track.id) { 31 | track.isLiked = false 32 | } 33 | return track 34 | }) 35 | } 36 | } 37 | }) 38 | 39 | export const { 40 | setCurTracks, 41 | setPosition, 42 | removeLikeFromCurTrack, 43 | addLikeToCurTrack 44 | } = curTracksSlice.actions 45 | 46 | export default curTracksSlice.reducer 47 | -------------------------------------------------------------------------------- /src/widgets/Messenger/Messenger.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/constants/colors.scss'; 2 | 3 | .messenger { 4 | z-index: 999; 5 | position: absolute; 6 | bottom: 5px; 7 | right: 5px; 8 | width: 75%; 9 | height: 75%; 10 | border-radius: 14px; 11 | overflow: hidden; 12 | } 13 | 14 | .shadow { 15 | z-index: 998; 16 | width: 100vw; 17 | height: 100vh; 18 | background-color: $primary-black; 19 | opacity: 0.5; 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | } 24 | 25 | .button { 26 | display: flex; 27 | justify-content: center; 28 | padding: 15px; 29 | align-items: center; 30 | z-index: 999; 31 | position: absolute; 32 | bottom: 5px; 33 | right: 5px; 34 | border: 0; 35 | background-color: $primary-grey; 36 | opacity: 0.7; 37 | transition: all 0.2s ease-in-out; 38 | border-radius: 50%; 39 | } 40 | 41 | .button svg { 42 | color: $primary-white; 43 | } 44 | 45 | .button:hover { 46 | background-color: $primary-violet; 47 | opacity: 1; 48 | box-shadow: 49 | 0 0 5px $primary-violet, 50 | 0 0 25px $primary-violet, 51 | 0 0 50px $primary-violet; 52 | } 53 | 54 | @media screen and (max-width: 750px) { 55 | .button { 56 | display: none; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/AutoComplete/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 4 | 5 | import { setNotification } from 'entities/Notification/slice' 6 | import { Severity } from 'entities/Notification/types' 7 | 8 | import { AutoCompleteProps } from '../types' 9 | 10 | import { RootState } from 'shared/Redux/store' 11 | import { request } from 'shared/Request/Requets' 12 | 13 | export function useDebounce( 14 | field: AutoCompleteProps['field'], 15 | dispatch: ThunkDispatch 16 | ) { 17 | if (!field.value) return 18 | useLayoutEffect(() => { 19 | const timeoutId = setTimeout(() => { 20 | request 21 | .sendRequest({ 22 | method: 'POST', 23 | url: field?.path, 24 | data: { [field?.name]: field?.value } 25 | }) 26 | .then(res => { 27 | field?.setOptions(res.data) 28 | }) 29 | .catch(e => { 30 | console.error(e) 31 | dispatch( 32 | setNotification({ 33 | severity: Severity.error, 34 | message: e.message 35 | }) 36 | ) 37 | }) 38 | }, 500) 39 | return () => { 40 | clearTimeout(timeoutId) 41 | } 42 | }, [field.value]) 43 | } 44 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/CodeForm/onSubmit.ts: -------------------------------------------------------------------------------- 1 | import { NavigateFunction } from 'react-router-dom' 2 | 3 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 4 | 5 | import { t } from 'i18next' 6 | 7 | import { setIsOpen, setNotification } from 'entities/Notification/slice' 8 | import { Severity } from 'entities/Notification/types' 9 | import { sendCodeThunk } from 'entities/User/thunk' 10 | 11 | import { RootState } from 'shared/Redux/store' 12 | 13 | export async function onSubmit( 14 | confirmationCode: string, 15 | dispatch: ThunkDispatch, 16 | navigate: NavigateFunction 17 | ) { 18 | if (!confirmationCode || confirmationCode.length !== 6) { 19 | dispatch( 20 | setNotification({ 21 | message: t('Alert.enterCode'), 22 | severity: Severity.info 23 | }) 24 | ) 25 | dispatch(setIsOpen(true)) 26 | return 27 | } 28 | const userId = await dispatch( 29 | sendCodeThunk(confirmationCode) as unknown as UnknownAction 30 | ) 31 | 32 | if (userId as unknown as boolean) { 33 | navigate(`/auth/resetPassword/${userId}`) 34 | dispatch( 35 | setNotification({ 36 | message: t('Alert.enterNewPassword'), 37 | severity: Severity.info 38 | }) 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/shared/utils/__tests__/validateEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | // Создаем простую функцию для тестирования логики валидации email 4 | function validateEmailLogic(email: string): boolean { 5 | const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ 6 | return regex.test(email) 7 | } 8 | 9 | describe('validateEmail', () => { 10 | it('должен возвращать true для валидных email адресов', () => { 11 | expect(validateEmailLogic('test@example.com')).toBe(true) 12 | expect(validateEmailLogic('user.name@domain.co.uk')).toBe(true) 13 | expect(validateEmailLogic('user+tag@example.org')).toBe(true) 14 | expect(validateEmailLogic('test123@test-domain.com')).toBe(true) 15 | }) 16 | 17 | it('должен возвращать false для невалидных email адресов', () => { 18 | expect(validateEmailLogic('invalid-email')).toBe(false) 19 | expect(validateEmailLogic('@example.com')).toBe(false) 20 | expect(validateEmailLogic('test@')).toBe(false) 21 | expect(validateEmailLogic('test.example.com')).toBe(false) 22 | expect(validateEmailLogic('test@.com')).toBe(false) 23 | expect(validateEmailLogic('test@example.')).toBe(false) 24 | expect(validateEmailLogic('')).toBe(false) 25 | expect(validateEmailLogic('test space@example.com')).toBe(false) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/AutoComplete/AutoComplete.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | 3 | import { Autocomplete } from '@mui/material' 4 | import TextField from '@mui/material/TextField' 5 | 6 | import { AutoCompleteProps } from './types' 7 | 8 | import { useAppDispatch } from 'shared/Redux/hooks' 9 | 10 | import { useDebounce } from './hooks/useDebounce' 11 | 12 | import styles from './AutoComplete.module.scss' 13 | 14 | export const AutoComplete = memo(function AutoComplete({ 15 | field 16 | }: AutoCompleteProps) { 17 | const dispatch = useAppDispatch() 18 | useDebounce(field, dispatch) 19 | return ( 20 |
21 | option)} 25 | renderInput={params => ( 26 | field?.onChange(e.target.value)} 34 | variant="standard" 35 | InputProps={{ 36 | ...params.InputProps, 37 | type: 'search' 38 | }} 39 | /> 40 | )} 41 | /> 42 |
43 | ) 44 | }) 45 | -------------------------------------------------------------------------------- /src/shared/UI/Inputs/Select/Select.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | import FormControl from '@mui/material/FormControl' 4 | import InputLabel from '@mui/material/InputLabel' 5 | import MenuItem from '@mui/material/MenuItem' 6 | import Select from '@mui/material/Select' 7 | 8 | import { Theme } from 'entities/App/types' 9 | 10 | import { SelectProps } from './types' 11 | 12 | export default function SelectInput({ 13 | label, 14 | options, 15 | value, 16 | onChange, 17 | dispatch 18 | }: SelectProps) { 19 | return ( 20 | 21 | 22 | {label} 23 | 24 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/CodePage/CodePage.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/constants/colors.scss'; 2 | 3 | .container { 4 | width: 100vw; 5 | height: 100vh; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | background-size: 300% 300%; 10 | background-image: linear-gradient( 11 | 90deg, 12 | rgba(243, 243, 243, 1) 0%, 13 | rgba(94, 233, 191, 1) 19%, 14 | rgba(47, 105, 255, 1) 54%, 15 | rgba(99, 96, 255, 1) 82%, 16 | rgba(161, 106, 232, 1) 100% 17 | ); 18 | animation: AnimateBG 20s ease infinite; 19 | } 20 | 21 | .codeContainer { 22 | width: 25vw; 23 | height: 30vh; 24 | border: 0px transparent solid; 25 | border-radius: 14px; 26 | display: flex; 27 | gap: 20px; 28 | flex-direction: column; 29 | align-items: center; 30 | justify-content: center; 31 | background-color: $primary-white; 32 | box-shadow: 0px 5px 50px $primary-grey; 33 | } 34 | 35 | .dark { 36 | background-color: $primary-black; 37 | color: $primary-white; 38 | } 39 | 40 | .light { 41 | background-color: $primary-white; 42 | color: $primary-black; 43 | } 44 | 45 | @keyframes AnimateBG { 46 | 0% { 47 | background-position: 0% 50%; 48 | } 49 | 50% { 50 | background-position: 100% 50%; 51 | } 52 | 100% { 53 | background-position: 0% 50%; 54 | } 55 | } 56 | 57 | @media screen and (max-width: 479px) { 58 | .codeContainer { 59 | width: 80%; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/EmailForm/onSubmit.ts: -------------------------------------------------------------------------------- 1 | import { type FormEvent } from 'react' 2 | import { type NavigateFunction } from 'react-router-dom' 3 | 4 | import { type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit' 5 | 6 | import { t } from 'i18next' 7 | 8 | import { setIsOpen, setNotification } from 'entities/Notification/slice' 9 | import { Severity } from 'entities/Notification/types' 10 | import { findEmailThunk } from 'entities/User/thunk' 11 | 12 | import { type RootState } from 'shared/Redux/store' 13 | import { validateEmail } from 'shared/utils/validateEmail' 14 | 15 | export async function onSubmit( 16 | e: FormEvent, 17 | dispatch: ThunkDispatch, 18 | navigate: NavigateFunction 19 | ) { 20 | e.preventDefault() 21 | 22 | const form = e.currentTarget 23 | const formData = { email: form.email.value as string } 24 | if (!formData.email) { 25 | dispatch( 26 | setNotification({ 27 | message: t('Alert.enterEmail'), 28 | severity: Severity.info 29 | }) 30 | ) 31 | dispatch(setIsOpen(true)) 32 | return 33 | } 34 | if (!validateEmail(formData.email, dispatch)) { 35 | return 36 | } 37 | const isSent = await dispatch( 38 | findEmailThunk(formData) as unknown as UnknownAction 39 | ) 40 | 41 | if (isSent as unknown as boolean) { 42 | navigate('/auth/codePage') 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/ResetPasswordPage/ResetPasswordPage.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useState } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | 5 | import { Typography } from 'nirvana-uikit' 6 | 7 | import { useAppSelector } from 'shared/Redux/hooks' 8 | import ResetPasswordForm from 'shared/UI/Forms/AuthForms/ResetPasswordForm/ResetPasswordForm' 9 | 10 | import styles from './ResetPasswordPage.module.scss' 11 | 12 | export default function ResetPasswordPage(): JSX.Element { 13 | const [isVisible, setIsVisible] = useState(false) 14 | const { t } = useTranslation() 15 | const { theme } = useAppSelector(state => state.app) 16 | return ( 17 |
18 |
23 |
24 | {isVisible ? ( 25 | 26 | ) : ( 27 | 28 | )} 29 | 34 |
35 |
36 | 40 |
41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/shared/UI/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useEffect } from 'react' 2 | 3 | import MuiAlert, { AlertColor, AlertProps } from '@mui/material/Alert' 4 | import Fade from '@mui/material/Fade' 5 | import Snackbar from '@mui/material/Snackbar' 6 | 7 | import { clearNotification, setIsOpen } from 'entities/Notification/slice' 8 | 9 | import { ToastProps } from './types' 10 | 11 | import { useAppDispatch, useAppSelector } from 'shared/Redux/hooks' 12 | 13 | const Alert = forwardRef( 14 | function Alert(props, ref) { 15 | return 16 | } 17 | ) 18 | 19 | export default function Toast({ notification }: ToastProps) { 20 | const { isOpen } = useAppSelector(state => state.notification) 21 | const dispatch = useAppDispatch() 22 | 23 | const handleClose = () => { 24 | dispatch(clearNotification()) 25 | } 26 | 27 | useEffect(() => { 28 | dispatch(setIsOpen(true)) 29 | }, [notification.message, notification.severity]) 30 | 31 | return ( 32 | <> 33 | 39 | 43 | {notification.message} 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Мusic streaming app 33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/EmailForm/EmailForm.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { type CSSProperties } from 'react' 3 | import { useTranslation } from 'react-i18next' 4 | import { useNavigate } from 'react-router-dom' 5 | 6 | import { onSubmit } from './onSubmit' 7 | 8 | import { useAppDispatch } from 'shared/Redux/hooks' 9 | 10 | import styles from './EmailForm.module.scss' 11 | 12 | function EmailForm() { 13 | const dispatch = useAppDispatch() 14 | const navigate = useNavigate() 15 | const { t } = useTranslation() 16 | return ( 17 |
{ 20 | await onSubmit(e, dispatch, navigate) 21 | }} 22 | > 23 |
    24 |
    28 |
  • 29 | 34 |
  • 35 |
    36 |
    40 | 47 |
    48 |
49 |
50 | ) 51 | } 52 | 53 | export default EmailForm 54 | -------------------------------------------------------------------------------- /src/shared/UI/Forms/AuthForms/LoginForm/onSubmit.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent } from 'react' 2 | import { NavigateFunction } from 'react-router-dom' 3 | 4 | import { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit' 5 | 6 | import { t } from 'i18next' 7 | 8 | import { setIsOpen, setNotification } from 'entities/Notification/slice' 9 | import { Severity } from 'entities/Notification/types' 10 | import { loginUserThunk } from 'entities/User/thunk' 11 | 12 | import { RootState } from 'shared/Redux/store' 13 | 14 | export async function onSubmit( 15 | e: FormEvent, 16 | dispatch: ThunkDispatch, 17 | navigate: NavigateFunction 18 | ) { 19 | e.preventDefault() 20 | 21 | const form = e.currentTarget 22 | const formData = { 23 | email: form.email.value, 24 | password: form.password.value 25 | } 26 | if (!formData.email) { 27 | dispatch( 28 | setNotification({ 29 | message: t('Alert.enterEmail'), 30 | severity: Severity.info 31 | }) 32 | ) 33 | dispatch(setIsOpen(true)) 34 | return 35 | } 36 | if (!formData.password) { 37 | dispatch( 38 | setNotification({ 39 | message: t('Alert.enterPassword'), 40 | severity: Severity.info 41 | }) 42 | ) 43 | dispatch(setIsOpen(true)) 44 | return 45 | } 46 | const isLogged = await dispatch( 47 | loginUserThunk(formData) as unknown as UnknownAction 48 | ) 49 | if (isLogged as unknown as boolean) { 50 | navigate('/') 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/widgets/Messenger/Messenger.tsx: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { useState } from 'react' 3 | 4 | import ChatIcon from '@mui/icons-material/Chat' 5 | import i18next from 'i18next' 6 | 7 | import { useAppSelector } from 'shared/Redux/hooks' 8 | 9 | import styles from './Messenger.module.scss' 10 | 11 | export const Messenger = () => { 12 | const [isOpen, setIsOpen] = useState(false) 13 | const user = useAppSelector(state => state.user) 14 | const { theme } = useAppSelector(state => state.app) 15 | 16 | return ( 17 | <> 18 | {isOpen ? ( 19 | <> 20 |
setIsOpen(false)} 23 | >
24 |
25 |