Repository: it-incubator/musicfun-react-all-stacks Branch: develop Commit: ef8e3e8520b9 Files: 2217 Total size: 3.0 MB Directory structure: gitextract_xkduu9og/ ├── .github/ │ └── workflows/ │ ├── ci-rtk.yml │ ├── ci-tanstack.yml │ ├── deploy-effector.yml │ ├── deploy-reatom.yml │ ├── deploy-root.yml │ ├── deploy-rtk.yml │ ├── deploy-tanstack.yml │ └── deploy.yml ├── .gitignore ├── .husky/ │ ├── pre-commit │ └── pre-push ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── FRONTEND_API_CHANGES.md ├── README.md ├── apps/ │ ├── nextjs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.mjs │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── actions/ │ │ │ │ │ └── auth/ │ │ │ │ │ └── logout.action.tsx │ │ │ │ ├── api/ │ │ │ │ │ └── oauth/ │ │ │ │ │ └── callback/ │ │ │ │ │ └── route.ts │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.module.css │ │ │ │ ├── page.tsx │ │ │ │ ├── profile/ │ │ │ │ │ └── page.tsx │ │ │ │ └── redirect/ │ │ │ │ └── page.tsx │ │ │ ├── features/ │ │ │ │ └── auth/ │ │ │ │ └── ui/ │ │ │ │ ├── Login/ │ │ │ │ │ └── Login.tsx │ │ │ │ ├── Logout/ │ │ │ │ │ └── Logout.tsx │ │ │ │ ├── MeInfo/ │ │ │ │ │ └── MeInfo.tsx │ │ │ │ └── UserBlock.tsx │ │ │ ├── middleware.ts │ │ │ ├── reauth-middleware.ts │ │ │ └── shared/ │ │ │ ├── api/ │ │ │ │ ├── auth-api.ts │ │ │ │ ├── authApi.types.ts │ │ │ │ ├── base.ts │ │ │ │ └── tracks/ │ │ │ │ ├── tracksApi.ts │ │ │ │ └── tracksApi.types.ts │ │ │ ├── common.types.ts │ │ │ └── utils/ │ │ │ ├── cookieHelpers.ts │ │ │ └── jwt-util.ts │ │ └── tsconfig.json │ ├── react-effector-fsd/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── App.tsx │ │ │ │ ├── main.tsx │ │ │ │ ├── model/ │ │ │ │ │ └── init.ts │ │ │ │ ├── routes/ │ │ │ │ │ ├── Routing.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── styles/ │ │ │ │ ├── fonts.css │ │ │ │ ├── global.css │ │ │ │ ├── reset.css │ │ │ │ └── variables.css │ │ │ ├── features/ │ │ │ │ └── auth/ │ │ │ │ ├── api/ │ │ │ │ │ ├── login.ts │ │ │ │ │ ├── logout.ts │ │ │ │ │ └── me.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model/ │ │ │ │ │ ├── auth-api.types.ts │ │ │ │ │ ├── model.ts │ │ │ │ │ └── user.types.ts │ │ │ │ └── ui/ │ │ │ │ ├── LoginButtonAndModal/ │ │ │ │ │ ├── LoginButtonAndModal.module.css │ │ │ │ │ ├── LoginButtonAndModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ProfileDropdownMenu/ │ │ │ │ │ ├── ProfileDropdownMenu.module.css │ │ │ │ │ ├── ProfileDropdownMenu.stories.tsx │ │ │ │ │ ├── ProfileDropdownMenu.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── pages/ │ │ │ │ ├── auth/ │ │ │ │ │ └── OAuthRedirect/ │ │ │ │ │ ├── OAuthCallback.module.css │ │ │ │ │ └── OAuthCallback.tsx │ │ │ │ ├── home/ │ │ │ │ │ ├── Home.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── user/ │ │ │ │ ├── UserPage.tsx │ │ │ │ └── index.ts │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── json-api-error.ts │ │ │ │ │ └── request-wrapper.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── AudioPlayer/ │ │ │ │ │ │ ├── AudioPlayer.module.css │ │ │ │ │ │ ├── AudioPlayer.stories.tsx │ │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Autocomplete/ │ │ │ │ │ │ ├── Autocomplete.module.css │ │ │ │ │ │ ├── Autocomplete.stories.tsx │ │ │ │ │ │ ├── Autocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Button/ │ │ │ │ │ │ ├── Button.module.css │ │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Card/ │ │ │ │ │ │ ├── Card.module.css │ │ │ │ │ │ ├── Card.stories.tsx │ │ │ │ │ │ ├── Card.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Dialog/ │ │ │ │ │ │ ├── Dialog.module.css │ │ │ │ │ │ ├── Dialog.stories.tsx │ │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── DropdownMenu/ │ │ │ │ │ │ ├── DropdownMenu.module.css │ │ │ │ │ │ ├── DropdownMenu.stories.tsx │ │ │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Hashtag/ │ │ │ │ │ │ ├── Tag.module.css │ │ │ │ │ │ ├── Tag.stories.tsx │ │ │ │ │ │ ├── Tag.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── IconButton/ │ │ │ │ │ │ ├── IconButton.module.css │ │ │ │ │ │ ├── IconButton.stories.tsx │ │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageUploader/ │ │ │ │ │ │ ├── ImageUploader.module.css │ │ │ │ │ │ ├── ImageUploader.stories.tsx │ │ │ │ │ │ ├── ImageUploader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Pagination/ │ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ │ ├── Pagination.stories.tsx │ │ │ │ │ │ ├── Pagination.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Progress/ │ │ │ │ │ │ ├── Progress.module.css │ │ │ │ │ │ ├── Progress.stories.tsx │ │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ReactionButtons/ │ │ │ │ │ │ ├── ReactionButtons.module.css │ │ │ │ │ │ ├── ReactionButtons.stories.tsx │ │ │ │ │ │ ├── ReactionButtons.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchField/ │ │ │ │ │ │ ├── SearchField.module.css │ │ │ │ │ │ ├── SearchField.stories.tsx │ │ │ │ │ │ ├── SearchField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Select/ │ │ │ │ │ │ ├── Select.module.css │ │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ │ ├── Select.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ └── Select.tsx │ │ │ │ │ ├── Table/ │ │ │ │ │ │ ├── Table.module.css │ │ │ │ │ │ ├── Table.stories.tsx │ │ │ │ │ │ ├── Table.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Tabs/ │ │ │ │ │ │ ├── Tabs.module.css │ │ │ │ │ │ ├── Tabs.stories.tsx │ │ │ │ │ │ ├── Tabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TagEditor/ │ │ │ │ │ │ ├── TagEditor.module.css │ │ │ │ │ │ ├── TagEditor.stories.tsx │ │ │ │ │ │ ├── TagEditor.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TextField/ │ │ │ │ │ │ ├── TextField.module.css │ │ │ │ │ │ ├── TextField.stories.tsx │ │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Textarea/ │ │ │ │ │ │ ├── Textarea.module.css │ │ │ │ │ │ ├── Textarea.stories.tsx │ │ │ │ │ │ ├── Textarea.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Typography/ │ │ │ │ │ │ ├── Typography.module.css │ │ │ │ │ │ ├── Typography.stories.tsx │ │ │ │ │ │ ├── Typography.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── config/ │ │ │ │ │ └── config.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useDebounceValue.ts │ │ │ │ │ └── useGetId.ts │ │ │ │ └── icons/ │ │ │ │ ├── AddToPlaylistIcon.tsx │ │ │ │ ├── ArrowDownIcon.tsx │ │ │ │ ├── ClockIcon.tsx │ │ │ │ ├── CreateIcon.tsx │ │ │ │ ├── DeleteIcon.tsx │ │ │ │ ├── DislikeIcon.tsx │ │ │ │ ├── DownloadIcon.tsx │ │ │ │ ├── EditIcon.tsx │ │ │ │ ├── HomeIcon.tsx │ │ │ │ ├── ImageUploadIcon.tsx │ │ │ │ ├── KeyboardArrowLeftIcon.tsx │ │ │ │ ├── KeyboardArrowRightIcon.tsx │ │ │ │ ├── LibraryIcon.tsx │ │ │ │ ├── LikeIcon.tsx │ │ │ │ ├── LikeIconFill.tsx │ │ │ │ ├── LikeInSquareIcon.tsx │ │ │ │ ├── LiveWaveIcon/ │ │ │ │ │ ├── LiveWaveIcon.module.css │ │ │ │ │ ├── LiveWaveIcon.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── LogoutIcon.tsx │ │ │ │ ├── MoreIcon.tsx │ │ │ │ ├── PauseIcon.tsx │ │ │ │ ├── PlayIcon.tsx │ │ │ │ ├── PlaylistIcon.tsx │ │ │ │ ├── PlusIcon.tsx │ │ │ │ ├── ProfileIcon.tsx │ │ │ │ ├── RepeatIcon.tsx │ │ │ │ ├── SearchIcon.tsx │ │ │ │ ├── ShuffleIcon.tsx │ │ │ │ ├── SkipNextIcon.tsx │ │ │ │ ├── SkipPreviousIcon.tsx │ │ │ │ ├── TextIcon.tsx │ │ │ │ ├── TrackIcon.tsx │ │ │ │ ├── UploadIcon.tsx │ │ │ │ ├── VolumeIcon.tsx │ │ │ │ ├── VolumeMuteIcon.tsx │ │ │ │ └── index.ts │ │ │ └── widgets/ │ │ │ └── layout/ │ │ │ ├── index.ts │ │ │ └── ui/ │ │ │ ├── Header/ │ │ │ │ ├── Header.module.css │ │ │ │ └── Header.tsx │ │ │ ├── Layout.module.css │ │ │ ├── Layout.tsx │ │ │ └── Sidebar/ │ │ │ ├── MenuLinks/ │ │ │ │ ├── MenuLinks.module.css │ │ │ │ └── MenuLinks.tsx │ │ │ ├── Sidebar.module.css │ │ │ └── Sidebar.tsx │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── react-native-expo/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── app/ │ │ │ ├── (app)/ │ │ │ │ ├── _layout.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── library/ │ │ │ │ │ └── library.tsx │ │ │ │ ├── playlists/ │ │ │ │ │ └── playlists.tsx │ │ │ │ └── tracks/ │ │ │ │ └── tracks.tsx │ │ │ ├── (auth)/ │ │ │ │ ├── _layout.tsx │ │ │ │ └── login.tsx │ │ │ └── _layout.tsx │ │ ├── app.config.ts │ │ ├── babel.config.js │ │ ├── declarations.d.ts │ │ ├── features/ │ │ │ ├── auth/ │ │ │ │ ├── components/ │ │ │ │ │ ├── LoginButton/ │ │ │ │ │ │ └── LoginButton.tsx │ │ │ │ │ └── LogoutButton/ │ │ │ │ │ └── LogoutButton.tsx │ │ │ │ └── model/ │ │ │ │ ├── api/ │ │ │ │ │ ├── auth-instanse/ │ │ │ │ │ │ └── auth-instanse.ts │ │ │ │ │ └── hooks/ │ │ │ │ │ ├── use-login-mutatuion.ts │ │ │ │ │ ├── use-logout-mutation.ts │ │ │ │ │ └── use-me.query.ts │ │ │ │ ├── config/ │ │ │ │ │ └── oauth.ts │ │ │ │ ├── context/ │ │ │ │ │ └── AuthContext.tsx │ │ │ │ ├── types/ │ │ │ │ │ └── api.types.ts │ │ │ │ └── utils/ │ │ │ │ ├── expoUrlToHttpCallback.ts │ │ │ │ └── getOauthRedirectUrl.ts │ │ │ └── playlists/ │ │ │ └── model/ │ │ │ └── api/ │ │ │ └── playlist-instance/ │ │ │ └── playlist-instance.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── package.json │ │ ├── pnpm-lock.yaml.2457664388 │ │ ├── shared/ │ │ │ ├── api/ │ │ │ │ ├── api-root/ │ │ │ │ │ ├── api-root-instanse.ts │ │ │ │ │ └── api-root.ts │ │ │ │ ├── query-client/ │ │ │ │ │ └── queryClient.ts │ │ │ │ └── query-persist/ │ │ │ │ └── query-presist.ts │ │ │ ├── consts/ │ │ │ │ ├── consts.ts │ │ │ │ └── key-storage/ │ │ │ │ └── key-storage.ts │ │ │ ├── providers/ │ │ │ │ └── reactQueryProviders/ │ │ │ │ └── ReactQueryProviders.tsx │ │ │ ├── storage/ │ │ │ │ └── tokenStorage.ts │ │ │ ├── styles/ │ │ │ │ ├── tokens.ts │ │ │ │ └── tokens.type.ts │ │ │ ├── ui/ │ │ │ │ ├── Button/ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ └── Button.type.tsx │ │ │ │ └── Icons/ │ │ │ │ ├── navigation/ │ │ │ │ │ ├── IcAllPlaylist.tsx │ │ │ │ │ ├── IcAllTracks.tsx │ │ │ │ │ ├── IcHome.tsx │ │ │ │ │ └── IcYourLibrary.tsx │ │ │ │ └── screens/ │ │ │ │ └── login/ │ │ │ │ └── IcSmile.tsx │ │ │ └── utils/ │ │ │ └── makeFullUrl.ts │ │ └── tsconfig.json │ ├── reatom/ │ │ ├── .gitignore │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.tsx │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── App.tsx │ │ │ │ ├── entrypoint/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── query-client/ │ │ │ │ │ └── query-client.tsx │ │ │ │ ├── routing/ │ │ │ │ │ ├── Routing.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── styles/ │ │ │ │ ├── fonts.css │ │ │ │ ├── global.css │ │ │ │ ├── reset.css │ │ │ │ └── variables.css │ │ │ ├── entities/ │ │ │ │ └── playlist/ │ │ │ │ ├── index.tsx │ │ │ │ └── ui/ │ │ │ │ ├── PlaylistCard/ │ │ │ │ │ ├── PlaylistCard.module.css │ │ │ │ │ ├── PlaylistCard.stories.tsx │ │ │ │ │ ├── PlaylistCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── PlaylistItem/ │ │ │ │ ├── PlaylistItem.tsx │ │ │ │ ├── PlaylistItem.types.ts │ │ │ │ └── index.ts │ │ │ ├── features/ │ │ │ │ ├── artists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── artists-api.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ └── ArtistCard/ │ │ │ │ │ ├── ArtistCard.module.css │ │ │ │ │ ├── ArtistCard.stories.tsx │ │ │ │ │ ├── ArtistCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── use-login.mutation.ts │ │ │ │ │ │ ├── use-logout.mutation.ts │ │ │ │ │ │ └── use-me.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── auth-api.types.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── LoginButtonAndModal/ │ │ │ │ │ │ ├── LoginButtonAndModal.module.css │ │ │ │ │ │ ├── LoginButtonAndModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ProfileDropdownMenu/ │ │ │ │ │ │ ├── ProfileDropdownMenu.module.css │ │ │ │ │ │ ├── ProfileDropdownMenu.stories.tsx │ │ │ │ │ │ ├── ProfileDropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── playlists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── playlistsApi.ts │ │ │ │ │ │ ├── query-key-factory.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── use-playlists.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ └── model.tsx │ │ │ │ │ └── ui/ │ │ │ │ │ ├── CreatePlaylistModal/ │ │ │ │ │ │ ├── CreatePlaylistModal.module.css │ │ │ │ │ │ ├── CreatePlaylistModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistOverview/ │ │ │ │ │ │ ├── PlaylistOverview.module.css │ │ │ │ │ │ ├── PlaylistOverview.stories.tsx │ │ │ │ │ │ ├── PlaylistOverview.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── reactions/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ReactionProvider/ │ │ │ │ │ │ ├── ReactionProvider.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── tags/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── tags-api.ts │ │ │ │ │ │ └── use-tags.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── TagsList/ │ │ │ │ │ │ ├── TagsList.module.css │ │ │ │ │ │ ├── TagsList.stories.tsx │ │ │ │ │ │ ├── TagsList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tracks/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── tracksApi.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── TrackCard/ │ │ │ │ │ ├── TrackCard.module.css │ │ │ │ │ ├── TrackCard.stories.tsx │ │ │ │ │ ├── TrackCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackInfoCell/ │ │ │ │ │ ├── TrackInfoCell.module.css │ │ │ │ │ ├── TrackInfoCell.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackOverview/ │ │ │ │ │ ├── TrackOverview.module.css │ │ │ │ │ ├── TrackOverview.stories.tsx │ │ │ │ │ ├── TrackOverview.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackRow/ │ │ │ │ │ ├── TrackRow.module.css │ │ │ │ │ └── TrackRow.tsx │ │ │ │ ├── TracksTable/ │ │ │ │ │ ├── TrackTable.stories.tsx │ │ │ │ │ ├── TracksTable.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── layout/ │ │ │ │ ├── Header/ │ │ │ │ │ ├── Header.module.css │ │ │ │ │ ├── Header.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Layout.module.css │ │ │ │ ├── Layout.tsx │ │ │ │ ├── Sidebar/ │ │ │ │ │ ├── MenuLinks/ │ │ │ │ │ │ ├── MenuLinks.module.css │ │ │ │ │ │ ├── MenuLinks.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Sidebar.module.css │ │ │ │ │ ├── Sidebar.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── pages/ │ │ │ │ ├── MainPage/ │ │ │ │ │ ├── MainPage.module.css │ │ │ │ │ ├── MainPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistPage/ │ │ │ │ │ ├── PlaylistPage.module.css │ │ │ │ │ ├── PlaylistPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ └── ControlPanel/ │ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistsPage/ │ │ │ │ │ ├── PlaylistsPage.module.css │ │ │ │ │ ├── PlaylistsPage.tsx │ │ │ │ │ ├── PlaylistsPage.types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackPage/ │ │ │ │ │ ├── TrackPage.module.css │ │ │ │ │ ├── TrackPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ └── ControlPanel/ │ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TracksPage/ │ │ │ │ │ ├── TracksPage.module.css │ │ │ │ │ ├── TracksPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── UserPage/ │ │ │ │ │ ├── UserPage.module.css │ │ │ │ │ ├── UserPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── UserInfo/ │ │ │ │ │ │ ├── UserInfo.module.css │ │ │ │ │ │ ├── UserInfo.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── UserTabs/ │ │ │ │ │ │ ├── LikedTracksTab/ │ │ │ │ │ │ │ ├── LikedTracksTab.module.css │ │ │ │ │ │ │ ├── LikedTracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── MyLikedPlaylistsTab/ │ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── PlaylistsTab/ │ │ │ │ │ │ │ ├── PlaylistsTab.module.css │ │ │ │ │ │ │ ├── PlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── TracksTab/ │ │ │ │ │ │ │ ├── TracksTab.module.css │ │ │ │ │ │ │ ├── TracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── UserTabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── auth/ │ │ │ │ │ └── OAuthRedirect/ │ │ │ │ │ ├── OAuthCallback.module.css │ │ │ │ │ └── OAuthCallback.tsx │ │ │ │ ├── common/ │ │ │ │ │ ├── ContentList/ │ │ │ │ │ │ ├── ContentList.module.css │ │ │ │ │ │ ├── ContentList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PageWrapper/ │ │ │ │ │ │ ├── PageWrapper.module.css │ │ │ │ │ │ ├── PageWrapper.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchTextField/ │ │ │ │ │ │ ├── SearchTextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ ├── SortSelect.module.css │ │ │ │ │ │ ├── SortSelect.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── json-api-error.ts │ │ │ │ │ └── request-wrapper.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── AudioPlayer/ │ │ │ │ │ │ ├── AudioPlayer.module.css │ │ │ │ │ │ ├── AudioPlayer.stories.tsx │ │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Autocomplete/ │ │ │ │ │ │ ├── Autocomplete.module.css │ │ │ │ │ │ ├── Autocomplete.stories.tsx │ │ │ │ │ │ ├── Autocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Button/ │ │ │ │ │ │ ├── Button.module.css │ │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Card/ │ │ │ │ │ │ ├── Card.module.css │ │ │ │ │ │ ├── Card.stories.tsx │ │ │ │ │ │ ├── Card.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Dialog/ │ │ │ │ │ │ ├── Dialog.module.css │ │ │ │ │ │ ├── Dialog.stories.tsx │ │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── DropdownMenu/ │ │ │ │ │ │ ├── DropdownMenu.module.css │ │ │ │ │ │ ├── DropdownMenu.stories.tsx │ │ │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Hashtag/ │ │ │ │ │ │ ├── Tag.module.css │ │ │ │ │ │ ├── Tag.stories.tsx │ │ │ │ │ │ ├── Tag.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── IconButton/ │ │ │ │ │ │ ├── IconButton.module.css │ │ │ │ │ │ ├── IconButton.stories.tsx │ │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageUploader/ │ │ │ │ │ │ ├── ImageUploader.module.css │ │ │ │ │ │ ├── ImageUploader.stories.tsx │ │ │ │ │ │ ├── ImageUploader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Pagination/ │ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ │ ├── Pagination.stories.tsx │ │ │ │ │ │ ├── Pagination.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Progress/ │ │ │ │ │ │ ├── Progress.module.css │ │ │ │ │ │ ├── Progress.stories.tsx │ │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ReactionButtons/ │ │ │ │ │ │ ├── ReactionButtons.module.css │ │ │ │ │ │ ├── ReactionButtons.stories.tsx │ │ │ │ │ │ ├── ReactionButtons.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchField/ │ │ │ │ │ │ ├── SearchField.module.css │ │ │ │ │ │ ├── SearchField.stories.tsx │ │ │ │ │ │ ├── SearchField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Select/ │ │ │ │ │ │ ├── Select.module.css │ │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ │ ├── Select.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ └── Select.tsx │ │ │ │ │ ├── Table/ │ │ │ │ │ │ ├── Table.module.css │ │ │ │ │ │ ├── Table.stories.tsx │ │ │ │ │ │ ├── Table.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Tabs/ │ │ │ │ │ │ ├── Tabs.module.css │ │ │ │ │ │ ├── Tabs.stories.tsx │ │ │ │ │ │ ├── Tabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TagEditor/ │ │ │ │ │ │ ├── TagEditor.module.css │ │ │ │ │ │ ├── TagEditor.stories.tsx │ │ │ │ │ │ ├── TagEditor.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TextField/ │ │ │ │ │ │ ├── TextField.module.css │ │ │ │ │ │ ├── TextField.stories.tsx │ │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Textarea/ │ │ │ │ │ │ ├── Textarea.module.css │ │ │ │ │ │ ├── Textarea.stories.tsx │ │ │ │ │ │ ├── Textarea.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Typography/ │ │ │ │ │ │ ├── Typography.module.css │ │ │ │ │ │ ├── Typography.stories.tsx │ │ │ │ │ │ ├── Typography.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── config/ │ │ │ │ │ └── config.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useDebounceValue.ts │ │ │ │ │ └── useGetId.ts │ │ │ │ ├── icons/ │ │ │ │ │ ├── AddToPlaylistIcon.tsx │ │ │ │ │ ├── ArrowDownIcon.tsx │ │ │ │ │ ├── ClockIcon.tsx │ │ │ │ │ ├── CreateIcon.tsx │ │ │ │ │ ├── DeleteIcon.tsx │ │ │ │ │ ├── DislikeIcon.tsx │ │ │ │ │ ├── DownloadIcon.tsx │ │ │ │ │ ├── EditIcon.tsx │ │ │ │ │ ├── HomeIcon.tsx │ │ │ │ │ ├── ImageUploadIcon.tsx │ │ │ │ │ ├── KeyboardArrowLeftIcon.tsx │ │ │ │ │ ├── KeyboardArrowRightIcon.tsx │ │ │ │ │ ├── LibraryIcon.tsx │ │ │ │ │ ├── LikeIcon.tsx │ │ │ │ │ ├── LikeIconFill.tsx │ │ │ │ │ ├── LikeInSquareIcon.tsx │ │ │ │ │ ├── LiveWaveIcon/ │ │ │ │ │ │ ├── LiveWaveIcon.module.css │ │ │ │ │ │ ├── LiveWaveIcon.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── LogoutIcon.tsx │ │ │ │ │ ├── MoreIcon.tsx │ │ │ │ │ ├── PauseIcon.tsx │ │ │ │ │ ├── PlayIcon.tsx │ │ │ │ │ ├── PlaylistIcon.tsx │ │ │ │ │ ├── PlusIcon.tsx │ │ │ │ │ ├── ProfileIcon.tsx │ │ │ │ │ ├── RepeatIcon.tsx │ │ │ │ │ ├── SearchIcon.tsx │ │ │ │ │ ├── ShuffleIcon.tsx │ │ │ │ │ ├── SkipNextIcon.tsx │ │ │ │ │ ├── SkipPreviousIcon.tsx │ │ │ │ │ ├── TextIcon.tsx │ │ │ │ │ ├── TrackIcon.tsx │ │ │ │ │ ├── UploadIcon.tsx │ │ │ │ │ ├── VolumeIcon.tsx │ │ │ │ │ ├── VolumeMuteIcon.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ui/ │ │ │ │ │ ├── prerender-ready.tsx │ │ │ │ │ └── utils/ │ │ │ │ │ └── query-error-handler-for-rhf-factory.ts │ │ │ │ └── utils/ │ │ │ │ ├── index.ts │ │ │ │ └── validators/ │ │ │ │ ├── getType.ts │ │ │ │ ├── inNun.ts │ │ │ │ ├── index.ts │ │ │ │ ├── isArray.ts │ │ │ │ ├── isFunction.ts │ │ │ │ ├── isNull.ts │ │ │ │ ├── isObject.ts │ │ │ │ ├── isUndefined.ts │ │ │ │ ├── isValid.ts │ │ │ │ └── isValidArray.ts │ │ │ ├── vite-env.d.ts │ │ │ └── widgets/ │ │ │ └── Player/ │ │ │ ├── Player.module.css │ │ │ ├── Player.tsx │ │ │ └── index.ts │ │ ├── stylelint.config.js │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── rtk-query/ │ │ ├── .gitignore │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.tsx │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── App.tsx │ │ │ │ ├── api/ │ │ │ │ │ ├── base-api.ts │ │ │ │ │ ├── base-query-with-refresh-token-flow-api.ts │ │ │ │ │ ├── handleError.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── routing/ │ │ │ │ │ ├── Routing.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── store/ │ │ │ │ ├── index.ts │ │ │ │ └── store.ts │ │ │ ├── features/ │ │ │ │ ├── artists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── artists-api.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ArtistCard/ │ │ │ │ │ │ ├── ArtistCard.module.css │ │ │ │ │ │ ├── ArtistCard.stories.tsx │ │ │ │ │ │ ├── ArtistCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ArtistsTagAutocomplete/ │ │ │ │ │ │ ├── ArtistsTagAutocomplete.module.css │ │ │ │ │ │ ├── ArtistsTagAutocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── auth-api.ts │ │ │ │ │ │ ├── auth-api.types.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── auth-slice.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── LoginModal/ │ │ │ │ │ │ ├── LoginModal.module.css │ │ │ │ │ │ ├── LoginModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── OAuthRedirect/ │ │ │ │ │ │ ├── OAuthCallback.module.css │ │ │ │ │ │ ├── OAuthCallback.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── playlists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── mocks.ts │ │ │ │ │ │ ├── playlistsApi.ts │ │ │ │ │ │ └── playlistsApi.types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── useCreatePlaylistModal.ts │ │ │ │ │ │ │ └── useEditPlaylistModal.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── playlists-slice.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ChoosePlaylistButtonAndModal/ │ │ │ │ │ │ ├── ChoosePlaylistButtonAndModal.module.css │ │ │ │ │ │ ├── ChoosePlaylistButtonAndModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ChoosePlaylistModal/ │ │ │ │ │ │ ├── ChoosePlaylistModal.module.css │ │ │ │ │ │ ├── ChoosePlaylistModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── CreateEditPlaylistModal/ │ │ │ │ │ │ ├── CreateEditPlaylistModal.module.css │ │ │ │ │ │ ├── CreateEditPlaylistModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistActions/ │ │ │ │ │ │ ├── PlaylistActions.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistCard/ │ │ │ │ │ │ ├── PlaylistCard.module.css │ │ │ │ │ │ ├── PlaylistCard.stories.tsx │ │ │ │ │ │ ├── PlaylistCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistCardSkeleton/ │ │ │ │ │ │ ├── PlaylistCardSkeleton.stories.tsx │ │ │ │ │ │ ├── PlaylistCardSkeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistOverview/ │ │ │ │ │ │ ├── PlaylistOverview.module.css │ │ │ │ │ │ ├── PlaylistOverview.stories.tsx │ │ │ │ │ │ ├── PlaylistOverview.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistRow/ │ │ │ │ │ │ ├── PlaylistRow.module.css │ │ │ │ │ │ └── PlaylistRow.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── profile/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── empty-profile.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── hook/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── useEditProfileModal.ts │ │ │ │ │ │ │ └── useEditProfileSchema.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── profile-schemas.ts │ │ │ │ │ │ └── profile-slice.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── profile.type.ts │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── EditProfileModal/ │ │ │ │ │ │ │ ├── EditProfileModal.module.css │ │ │ │ │ │ │ ├── EditProfileModal.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── storage-key.ts │ │ │ │ ├── tags/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── tagsApi.ts │ │ │ │ │ │ └── tagsApi.types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── PlaylistTagAutocomplete/ │ │ │ │ │ │ ├── PlaylistTagAutocomplete.module.css │ │ │ │ │ │ ├── PlaylistTagAutocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TagsList/ │ │ │ │ │ │ ├── TagsList.module.css │ │ │ │ │ │ ├── TagsList.stories.tsx │ │ │ │ │ │ ├── TagsList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tracks/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mocks.ts │ │ │ │ │ ├── tracksApi.ts │ │ │ │ │ └── tracksApi.types.ts │ │ │ │ ├── constants/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useCreateTrackModal.ts │ │ │ │ │ │ └── useEditTrackModal.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tracks-slice.ts │ │ │ │ ├── ui/ │ │ │ │ │ ├── CreateEditTrackModal/ │ │ │ │ │ │ ├── CreateEditTrackModal.module.css │ │ │ │ │ │ ├── CreateEditTrackModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackActions/ │ │ │ │ │ │ ├── TrackActions.tsx │ │ │ │ │ │ ├── TrackActionsMenu/ │ │ │ │ │ │ │ ├── TrackActionsMenu.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackCard/ │ │ │ │ │ │ ├── TrackCard.module.css │ │ │ │ │ │ ├── TrackCard.stories.tsx │ │ │ │ │ │ ├── TrackCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackInfoCell/ │ │ │ │ │ │ ├── TrackInfoCell.module.css │ │ │ │ │ │ ├── TrackInfoCell.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackOverview/ │ │ │ │ │ │ ├── TrackOverview.module.css │ │ │ │ │ │ ├── TrackOverview.stories.tsx │ │ │ │ │ │ ├── TrackOverview.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackRow/ │ │ │ │ │ │ ├── TrackRow.module.css │ │ │ │ │ │ └── TrackRow.tsx │ │ │ │ │ ├── TrackRowContainer/ │ │ │ │ │ │ ├── TrackRowContainer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TracksTable/ │ │ │ │ │ │ ├── TrackTable.stories.tsx │ │ │ │ │ │ ├── TracksTable.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TracksTableSkeleton/ │ │ │ │ │ │ ├── TracksTableSkeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ └── playlistSync.ts │ │ │ ├── layout/ │ │ │ │ ├── AppLoader/ │ │ │ │ │ ├── AppLoader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Header/ │ │ │ │ │ ├── AccountMenu/ │ │ │ │ │ │ ├── AccountMenu.module.css │ │ │ │ │ │ ├── AccountMenu.stories.tsx │ │ │ │ │ │ ├── AccountMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Header.module.css │ │ │ │ │ ├── Header.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Layout.module.css │ │ │ │ ├── Layout.tsx │ │ │ │ ├── Sidebar/ │ │ │ │ │ ├── MenuLinks/ │ │ │ │ │ │ ├── MenuLinks.module.css │ │ │ │ │ │ ├── MenuLinks.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Sidebar.module.css │ │ │ │ │ ├── Sidebar.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── MainPage/ │ │ │ │ │ ├── MainPage.module.css │ │ │ │ │ ├── MainPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistPage/ │ │ │ │ │ ├── PlaylistPage.module.css │ │ │ │ │ ├── PlaylistPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ControlPanel/ │ │ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── PlaylistPageSkeleton/ │ │ │ │ │ ├── PlaylistPageSkeleton.module.css │ │ │ │ │ ├── PlaylistPageSkeleton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistsPage/ │ │ │ │ │ ├── PlaylistsPage.module.css │ │ │ │ │ ├── PlaylistsPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackLyricsPage/ │ │ │ │ │ ├── TrackLyricsPage.module.css │ │ │ │ │ ├── TrackLyricsPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackPage/ │ │ │ │ │ ├── TrackPage.module.css │ │ │ │ │ ├── TrackPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ControlPanel/ │ │ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── TrackPageSkeleton/ │ │ │ │ │ ├── TrackPageSkeleton.module.css │ │ │ │ │ ├── TrackPageSkeleton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TracksPage/ │ │ │ │ │ ├── TracksPage.module.css │ │ │ │ │ ├── TracksPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── UserPage/ │ │ │ │ │ ├── UserPage.module.css │ │ │ │ │ ├── UserPage.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useOwnerData.ts │ │ │ │ │ │ └── useUserPageBackgroundColor.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── UserInfo/ │ │ │ │ │ │ ├── UserInfo.module.css │ │ │ │ │ │ ├── UserInfo.tsx │ │ │ │ │ │ ├── UserInfoSkeleton/ │ │ │ │ │ │ │ ├── UserInfoSkeleton.module.css │ │ │ │ │ │ │ ├── UserInfoSkeleton.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── UserStats/ │ │ │ │ │ │ │ ├── UserStats.module.css │ │ │ │ │ │ │ ├── UserStats.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── UserTabs/ │ │ │ │ │ │ ├── LikedTracksTab/ │ │ │ │ │ │ │ ├── LikedTracksTab.module.css │ │ │ │ │ │ │ ├── LikedTracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── MyLikedPlaylistsTab/ │ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.module.css │ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── PlaylistsTab/ │ │ │ │ │ │ │ ├── PlaylistsTab.module.css │ │ │ │ │ │ │ ├── PlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── TracksTab/ │ │ │ │ │ │ │ ├── TracksTab.module.css │ │ │ │ │ │ │ ├── TracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── UserTabs.tsx │ │ │ │ │ │ ├── UserTabsSkeleton/ │ │ │ │ │ │ │ ├── UserTabsSkeleton.module.css │ │ │ │ │ │ │ ├── UserTabsSkeleton.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── usePageBackgroundColor.ts │ │ │ │ │ │ └── usePageSearchParams.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ContentList/ │ │ │ │ │ │ ├── ContentList.module.css │ │ │ │ │ │ ├── ContentList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PageWithHeader/ │ │ │ │ │ │ ├── PageWithHeader.module.css │ │ │ │ │ │ ├── PageWithHeader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PageWithoutHeader/ │ │ │ │ │ │ ├── PageWithoutHeader.module.css │ │ │ │ │ │ ├── PageWithoutHeader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchTags/ │ │ │ │ │ │ ├── SearchTags.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchTextField/ │ │ │ │ │ │ ├── SearchTextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── SortSelect/ │ │ │ │ │ ├── SortSelect.module.css │ │ │ │ │ ├── SortSelect.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── player/ │ │ │ │ ├── MIGRATION_GUIDE.md │ │ │ │ ├── README.md │ │ │ │ ├── SPECIFICATION.md │ │ │ │ ├── index.ts │ │ │ │ ├── player.ts │ │ │ │ ├── playerHooks.ts │ │ │ │ ├── playerMiddleware.ts │ │ │ │ ├── playerSelectors.ts │ │ │ │ ├── playerSlice.ts │ │ │ │ ├── task.md │ │ │ │ ├── types/ │ │ │ │ │ └── player.types.ts │ │ │ │ └── utils/ │ │ │ │ ├── convert-api-track-to-player-track.ts │ │ │ │ ├── format-time.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shuffle.ts │ │ │ │ └── throttle.ts │ │ │ ├── shared/ │ │ │ │ ├── assets/ │ │ │ │ │ └── images/ │ │ │ │ │ └── no-cover-placeholder.avif │ │ │ │ ├── components/ │ │ │ │ │ ├── AudioPlayer/ │ │ │ │ │ │ ├── AudioPlayer.module.css │ │ │ │ │ │ ├── AudioPlayer.stories.tsx │ │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ │ ├── AudioPlayerSceleton/ │ │ │ │ │ │ │ ├── AudioPlayerSkeleton.module.css │ │ │ │ │ │ │ ├── AudioPlayerSkeleton.stories.tsx │ │ │ │ │ │ │ └── AudioPlayerSkeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Autocomplete/ │ │ │ │ │ │ ├── Autocomplete.module.css │ │ │ │ │ │ ├── Autocomplete.stories.tsx │ │ │ │ │ │ ├── Autocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Avatar/ │ │ │ │ │ │ ├── Avatar.module.css │ │ │ │ │ │ ├── Avatar.stories.tsx │ │ │ │ │ │ ├── Avatar.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Button/ │ │ │ │ │ │ ├── Button.module.css │ │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Card/ │ │ │ │ │ │ ├── Card.module.css │ │ │ │ │ │ ├── Card.stories.tsx │ │ │ │ │ │ ├── Card.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Dialog/ │ │ │ │ │ │ ├── Dialog.module.css │ │ │ │ │ │ ├── Dialog.stories.tsx │ │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── DropdownMenu/ │ │ │ │ │ │ ├── DropdownMenu.module.css │ │ │ │ │ │ ├── DropdownMenu.stories.tsx │ │ │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── FileUploader/ │ │ │ │ │ │ ├── FileUploader.module.css │ │ │ │ │ │ ├── FileUploader.stories.tsx │ │ │ │ │ │ ├── FileUploader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── FormControlledTextField/ │ │ │ │ │ │ ├── FormControlledTextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Hashtag/ │ │ │ │ │ │ ├── Tag.module.css │ │ │ │ │ │ ├── Tag.stories.tsx │ │ │ │ │ │ ├── Tag.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── IconButton/ │ │ │ │ │ │ ├── IconButton.module.css │ │ │ │ │ │ ├── IconButton.stories.tsx │ │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageCropper/ │ │ │ │ │ │ ├── ImageCropper.module.css │ │ │ │ │ │ ├── ImageCropper.stories.tsx │ │ │ │ │ │ ├── ImageCropper.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageUploader/ │ │ │ │ │ │ ├── ImageUploader.module.css │ │ │ │ │ │ ├── ImageUploader.stories.tsx │ │ │ │ │ │ ├── ImageUploader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Loader/ │ │ │ │ │ │ ├── Loader.module.css │ │ │ │ │ │ ├── Loader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Pagination/ │ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ │ ├── Pagination.stories.tsx │ │ │ │ │ │ ├── Pagination.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Progress/ │ │ │ │ │ │ ├── Progress.module.css │ │ │ │ │ │ ├── Progress.stories.tsx │ │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ReactionButtons/ │ │ │ │ │ │ ├── ReactionButtons.module.css │ │ │ │ │ │ ├── ReactionButtons.stories.tsx │ │ │ │ │ │ ├── ReactionButtons.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchField/ │ │ │ │ │ │ ├── SearchField.module.css │ │ │ │ │ │ ├── SearchField.stories.tsx │ │ │ │ │ │ ├── SearchField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Select/ │ │ │ │ │ │ ├── Select.module.css │ │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ │ ├── Select.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Skeleton/ │ │ │ │ │ │ ├── Skeleton.module.css │ │ │ │ │ │ ├── Skeleton.stories.tsx │ │ │ │ │ │ ├── Skeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ └── Select.tsx │ │ │ │ │ ├── Spinner/ │ │ │ │ │ │ ├── Spinner.stories.tsx │ │ │ │ │ │ ├── Spinner.tsx │ │ │ │ │ │ └── spinner.module.css │ │ │ │ │ ├── Table/ │ │ │ │ │ │ ├── Table.module.css │ │ │ │ │ │ ├── Table.stories.tsx │ │ │ │ │ │ ├── Table.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Tabs/ │ │ │ │ │ │ ├── Tabs.module.css │ │ │ │ │ │ ├── Tabs.stories.tsx │ │ │ │ │ │ ├── Tabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TagEditor/ │ │ │ │ │ │ ├── TagEditor.module.css │ │ │ │ │ │ ├── TagEditor.stories.tsx │ │ │ │ │ │ ├── TagEditor.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TextField/ │ │ │ │ │ │ ├── TextField.module.css │ │ │ │ │ │ ├── TextField.stories.tsx │ │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Textarea/ │ │ │ │ │ │ ├── Textarea.module.css │ │ │ │ │ │ ├── Textarea.stories.tsx │ │ │ │ │ │ ├── Textarea.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Typography/ │ │ │ │ │ │ ├── Typography.module.css │ │ │ │ │ │ ├── Typography.stories.tsx │ │ │ │ │ │ ├── Typography.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── configs/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── paths.ts │ │ │ │ ├── constants/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useAppDispatch.ts │ │ │ │ │ ├── useAppSelector.ts │ │ │ │ │ ├── useCurrentPage.ts │ │ │ │ │ ├── useDebounce.ts │ │ │ │ │ ├── useGetId.ts │ │ │ │ │ ├── useGlobalLoading.ts │ │ │ │ │ └── useHover.ts │ │ │ │ ├── icons/ │ │ │ │ │ ├── AddToPlaylistIcon.tsx │ │ │ │ │ ├── AddTrackIcon.tsx │ │ │ │ │ ├── ArrowBackIcon.tsx │ │ │ │ │ ├── ArrowDownIcon.tsx │ │ │ │ │ ├── CheckedIcon.tsx │ │ │ │ │ ├── ClockIcon.tsx │ │ │ │ │ ├── CreateIcon.tsx │ │ │ │ │ ├── DeleteIcon.tsx │ │ │ │ │ ├── DeleteTagIconButton.tsx │ │ │ │ │ ├── DislikeIcon.tsx │ │ │ │ │ ├── DownloadIcon.tsx │ │ │ │ │ ├── EditIcon.tsx │ │ │ │ │ ├── HomeIcon.tsx │ │ │ │ │ ├── IconOneRepeat.tsx │ │ │ │ │ ├── ImageUploadIcon.tsx │ │ │ │ │ ├── KeyboardArrowLeftIcon.tsx │ │ │ │ │ ├── KeyboardArrowRightIcon.tsx │ │ │ │ │ ├── LanguageIcon.tsx │ │ │ │ │ ├── LibraryIcon.tsx │ │ │ │ │ ├── LikeIcon.tsx │ │ │ │ │ ├── LikeIconFill.tsx │ │ │ │ │ ├── LikeInSquareIcon.tsx │ │ │ │ │ ├── LiveWaveIcon/ │ │ │ │ │ │ ├── LiveWaveIcon.module.css │ │ │ │ │ │ ├── LiveWaveIcon.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── LogoutIcon.tsx │ │ │ │ │ ├── MoreIcon.tsx │ │ │ │ │ ├── PauseIcon.tsx │ │ │ │ │ ├── PlayIcon.tsx │ │ │ │ │ ├── PlaylistIcon.tsx │ │ │ │ │ ├── PlusIcon.tsx │ │ │ │ │ ├── ProfileIcon.tsx │ │ │ │ │ ├── RepeatIcon.tsx │ │ │ │ │ ├── SearchIcon.tsx │ │ │ │ │ ├── ShuffleIcon.tsx │ │ │ │ │ ├── SkipNextIcon.tsx │ │ │ │ │ ├── SkipPreviousIcon.tsx │ │ │ │ │ ├── StaticWaveIcon.tsx │ │ │ │ │ ├── TextIcon.tsx │ │ │ │ │ ├── TrackIcon.tsx │ │ │ │ │ ├── UncheckedIcon.tsx │ │ │ │ │ ├── UploadIcon.tsx │ │ │ │ │ ├── VolumeIcon.tsx │ │ │ │ │ ├── VolumeMuteIcon.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── translations/ │ │ │ │ │ ├── i18nConfiguration.ts │ │ │ │ │ └── languages/ │ │ │ │ │ ├── en.json │ │ │ │ │ └── ru.json │ │ │ │ ├── types/ │ │ │ │ │ ├── common.types.ts │ │ │ │ │ ├── commonApi.types.ts │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ ├── build-query-string.ts │ │ │ │ ├── convert-file-to-base-64.ts │ │ │ │ ├── decode-file-from-base-64.ts │ │ │ │ ├── format-created-date.ts │ │ │ │ ├── get-image-by-type.ts │ │ │ │ ├── get-plural-key.ts │ │ │ │ ├── get-russian-plural-form.ts │ │ │ │ ├── get-user-initials.ts │ │ │ │ ├── index.ts │ │ │ │ ├── set-locale.ts │ │ │ │ └── show-error-toast.ts │ │ │ ├── styles/ │ │ │ │ ├── fonts.css │ │ │ │ ├── global.css │ │ │ │ ├── reset.css │ │ │ │ └── variables.css │ │ │ ├── vite-env.d.ts │ │ │ └── widgets/ │ │ │ └── Player/ │ │ │ ├── Player.module.css │ │ │ ├── Player.tsx │ │ │ └── index.ts │ │ ├── stylelint.config.js │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── tanstack-query-zustand/ │ │ ├── .claude/ │ │ │ └── skills/ │ │ │ └── i18n-rules/ │ │ │ └── SKILL.md │ │ ├── .gitignore │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.tsx │ │ ├── AGENTS.md │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── app/ │ │ │ │ ├── App.tsx │ │ │ │ ├── entrypoint/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── query-client/ │ │ │ │ │ └── query-client.tsx │ │ │ │ ├── routing/ │ │ │ │ │ ├── Routing.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── styles/ │ │ │ │ ├── fonts.css │ │ │ │ ├── global.css │ │ │ │ ├── reset.css │ │ │ │ └── variables.css │ │ │ ├── assets/ │ │ │ │ └── img/ │ │ │ │ └── no-cover-placeholder.avif │ │ │ ├── entities/ │ │ │ │ └── playlist/ │ │ │ │ ├── index.tsx │ │ │ │ └── ui/ │ │ │ │ ├── PlaylistCard/ │ │ │ │ │ ├── PlaylistCard.module.scss │ │ │ │ │ ├── PlaylistCard.stories.tsx │ │ │ │ │ ├── PlaylistCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistCardSkeleton/ │ │ │ │ │ ├── PlaylistCardSkeleton.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── PlaylistItem/ │ │ │ │ ├── PlaylistItem.tsx │ │ │ │ ├── PlaylistItem.types.ts │ │ │ │ └── index.ts │ │ │ ├── features/ │ │ │ │ ├── artists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── artists-api.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── use-artists.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ └── ArtistCard/ │ │ │ │ │ ├── ArtistCard.module.css │ │ │ │ │ ├── ArtistCard.stories.tsx │ │ │ │ │ ├── ArtistCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── use-login.mutation.ts │ │ │ │ │ │ ├── use-logout.mutation.ts │ │ │ │ │ │ └── use-me.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── auth-api.types.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── LoginButtonAndModal/ │ │ │ │ │ │ ├── LoginButtonAndModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── LoginModal/ │ │ │ │ │ │ ├── LoginModal.module.css │ │ │ │ │ │ ├── LoginModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ProfileDropdownMenu/ │ │ │ │ │ │ ├── ProfileDropdownMenu.module.css │ │ │ │ │ │ ├── ProfileDropdownMenu.stories.tsx │ │ │ │ │ │ ├── ProfileDropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── playlists/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── playlistsApi.ts │ │ │ │ │ │ ├── query-key-factory.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── use-playlist-mutations.ts │ │ │ │ │ │ ├── use-playlist.query.ts │ │ │ │ │ │ └── use-playlists.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ └── usePlaylistReactions.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ChoosePlaylistModal/ │ │ │ │ │ │ ├── ChoosePlaylistModal.module.css │ │ │ │ │ │ └── ChoosePlaylistModal.tsx │ │ │ │ │ ├── CreatePlaylistModal/ │ │ │ │ │ │ ├── CreatePlaylistModal.module.css │ │ │ │ │ │ ├── CreatePlaylistModal.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistOverview/ │ │ │ │ │ │ ├── PlaylistOverview.module.css │ │ │ │ │ │ ├── PlaylistOverview.stories.tsx │ │ │ │ │ │ ├── PlaylistOverview.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistRow/ │ │ │ │ │ │ ├── PlaylistRow.module.css │ │ │ │ │ │ ├── PlaylistRow.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── profile/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── empty-profile.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── use-edit-profile-modal.ts │ │ │ │ │ │ │ ├── use-edit-profile-schema.ts │ │ │ │ │ │ │ └── use-hydrate-profile.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── profile-schemas.ts │ │ │ │ │ │ └── profile-store.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── profile.types.ts │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── EditProfileModal/ │ │ │ │ │ │ │ ├── EditProfileModal.module.css │ │ │ │ │ │ │ ├── EditProfileModal.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── profile-storage.ts │ │ │ │ │ └── storage-key.ts │ │ │ │ ├── tags/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── tags-api.ts │ │ │ │ │ │ └── use-tags.query.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── TagsList/ │ │ │ │ │ │ ├── TagsList.module.css │ │ │ │ │ │ ├── TagsList.stories.tsx │ │ │ │ │ │ ├── TagsList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tracks/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── query-key-factory.ts │ │ │ │ │ ├── tracksApi.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── use-playlist-tracks.query.ts │ │ │ │ │ ├── use-track-mutations.ts │ │ │ │ │ ├── use-track.query.ts │ │ │ │ │ └── use-tracks.query.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model/ │ │ │ │ │ └── useTrackReactions.ts │ │ │ │ ├── ui/ │ │ │ │ │ ├── CreateTrackForm/ │ │ │ │ │ │ ├── CreateTrackModal.module.css │ │ │ │ │ │ └── CreateTrackModal.tsx │ │ │ │ │ ├── TrackActions/ │ │ │ │ │ │ ├── TrackActions.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackActionsMenu/ │ │ │ │ │ │ └── TrackActionsMenu.tsx │ │ │ │ │ ├── TrackCard/ │ │ │ │ │ │ ├── TrackCard.module.css │ │ │ │ │ │ ├── TrackCard.stories.tsx │ │ │ │ │ │ ├── TrackCard.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackInfoCell/ │ │ │ │ │ │ ├── TrackInfoCell.module.css │ │ │ │ │ │ ├── TrackInfoCell.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackOverview/ │ │ │ │ │ │ ├── TrackOverview.module.css │ │ │ │ │ │ ├── TrackOverview.stories.tsx │ │ │ │ │ │ ├── TrackOverview.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TrackRow/ │ │ │ │ │ │ ├── TrackRow.module.css │ │ │ │ │ │ └── TrackRow.tsx │ │ │ │ │ ├── TrackRowContainer/ │ │ │ │ │ │ ├── TrackRowContainer.module.css │ │ │ │ │ │ └── TrackRowContainer.tsx │ │ │ │ │ ├── TracksTable/ │ │ │ │ │ │ ├── TrackTable.stories.tsx │ │ │ │ │ │ ├── TracksTable.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TracksTableSkeleton/ │ │ │ │ │ │ ├── TracksTableSkeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ └── playlistSync.ts │ │ │ ├── index.css │ │ │ ├── layout/ │ │ │ │ ├── Header/ │ │ │ │ │ ├── Header.module.css │ │ │ │ │ ├── Header.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Layout.module.css │ │ │ │ ├── Layout.tsx │ │ │ │ ├── Sidebar/ │ │ │ │ │ ├── MenuLinks/ │ │ │ │ │ │ ├── MenuLinks.module.css │ │ │ │ │ │ ├── MenuLinks.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Sidebar.module.css │ │ │ │ │ ├── Sidebar.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── MainPage/ │ │ │ │ │ ├── MainPage.module.css │ │ │ │ │ ├── MainPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistPage/ │ │ │ │ │ ├── PlaylistPage.module.css │ │ │ │ │ ├── PlaylistPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ControlPanel/ │ │ │ │ │ │ ├── ControlPanel.module.scss │ │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── PlaylistPageSkeleton/ │ │ │ │ │ ├── PlaylistPageSkeleton.module.css │ │ │ │ │ ├── PlaylistPageSkeleton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistsPage/ │ │ │ │ │ ├── PlaylistsPage.module.css │ │ │ │ │ ├── PlaylistsPage.tsx │ │ │ │ │ ├── PlaylistsPage.types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── model/ │ │ │ │ │ ├── useCreatePlaylist.ts │ │ │ │ │ ├── useDeletePlaylist.ts │ │ │ │ │ └── useUploadPlaylistCover.ts │ │ │ │ ├── TrackLyricsPage/ │ │ │ │ │ ├── TrackLyricsPage.module.css │ │ │ │ │ ├── TrackLyricsPage.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TrackPage/ │ │ │ │ │ ├── TrackPage.module.css │ │ │ │ │ ├── TrackPage.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── ControlPanel/ │ │ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ │ │ ├── ControlPanel.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── TrackPageSkeleton/ │ │ │ │ │ ├── TrackPageSkeleton.module.css │ │ │ │ │ ├── TrackPageSkeleton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TracksPage/ │ │ │ │ │ ├── TracksPage.module.css │ │ │ │ │ ├── TracksPage.tsx │ │ │ │ │ ├── TracksSortFunction.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── useTrackDetails.ts │ │ │ │ │ │ ├── useTracksInfinityQuery.ts │ │ │ │ │ │ ├── useTracksQuery.tsx │ │ │ │ │ │ ├── useUploadTrack.ts │ │ │ │ │ │ └── useUploadTrackCover.ts │ │ │ │ │ └── tracksPageTypes/ │ │ │ │ │ └── TracksPageTypes.ts │ │ │ │ ├── UserPage/ │ │ │ │ │ ├── UserPage.module.css │ │ │ │ │ ├── UserPage.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useUserPageBackgroundColor.ts │ │ │ │ │ │ └── useUserPageData.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ui/ │ │ │ │ │ ├── UserInfo/ │ │ │ │ │ │ ├── UserInfo.module.css │ │ │ │ │ │ ├── UserInfo.tsx │ │ │ │ │ │ ├── UserInfoSkeleton.module.css │ │ │ │ │ │ ├── UserInfoSkeleton.tsx │ │ │ │ │ │ ├── UserStats.module.css │ │ │ │ │ │ ├── UserStats.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── UserTabs/ │ │ │ │ │ │ ├── LikedTracksTab/ │ │ │ │ │ │ │ ├── LikedTracksTab.module.css │ │ │ │ │ │ │ ├── LikedTracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── MyLikedPlaylistsTab/ │ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.module.css │ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── PlaylistsTab/ │ │ │ │ │ │ │ ├── PlaylistsTab.module.css │ │ │ │ │ │ │ ├── PlaylistsTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── TracksTab/ │ │ │ │ │ │ │ ├── TracksTab.module.css │ │ │ │ │ │ │ ├── TracksTab.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── UserTabs.tsx │ │ │ │ │ │ ├── UserTabsSkeleton/ │ │ │ │ │ │ │ ├── UserTabsSkeleton.module.css │ │ │ │ │ │ │ ├── UserTabsSkeleton.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── auth/ │ │ │ │ │ └── OAuthRedirect/ │ │ │ │ │ ├── OAuthCallback.module.css │ │ │ │ │ └── OAuthCallback.tsx │ │ │ │ ├── common/ │ │ │ │ │ ├── ContentList/ │ │ │ │ │ │ ├── ContentList.module.css │ │ │ │ │ │ ├── ContentList.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PageWithHeader/ │ │ │ │ │ │ ├── PageWithHeader.module.css │ │ │ │ │ │ └── PageWithHeader.tsx │ │ │ │ │ ├── PageWithoutHeader/ │ │ │ │ │ │ ├── PageWithoutHeader.module.css │ │ │ │ │ │ └── PageWithoutHeader.tsx │ │ │ │ │ ├── PageWrapper/ │ │ │ │ │ │ ├── PageWrapper.module.css │ │ │ │ │ │ ├── PageWrapper.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchTextField/ │ │ │ │ │ │ ├── SearchTextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ ├── SortSelect.module.css │ │ │ │ │ │ ├── SortSelect.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── player/ │ │ │ │ ├── README.md │ │ │ │ ├── SPECIFICATION.md │ │ │ │ ├── index.ts │ │ │ │ ├── model/ │ │ │ │ │ ├── audio-manager.ts │ │ │ │ │ ├── player-hooks.ts │ │ │ │ │ ├── player-store.ts │ │ │ │ │ └── player-track-hooks.ts │ │ │ │ ├── task.md │ │ │ │ ├── types/ │ │ │ │ │ └── player.types.ts │ │ │ │ └── utils/ │ │ │ │ ├── convert-api-track-to-player-track.ts │ │ │ │ ├── format-time.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shuffle.ts │ │ │ │ └── track-navigation.ts │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── json-api-error.ts │ │ │ │ │ └── unwrap.ts │ │ │ │ ├── auth/ │ │ │ │ │ └── types/ │ │ │ │ │ └── local-storage.keys.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── AudioPlayer/ │ │ │ │ │ │ ├── AudioPlayer.module.css │ │ │ │ │ │ ├── AudioPlayer.stories.tsx │ │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Autocomplete/ │ │ │ │ │ │ ├── Autocomplete.module.css │ │ │ │ │ │ ├── Autocomplete.stories.tsx │ │ │ │ │ │ ├── Autocomplete.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Avatar/ │ │ │ │ │ │ ├── Avatar.module.css │ │ │ │ │ │ ├── Avatar.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Button/ │ │ │ │ │ │ ├── Button.module.css │ │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Card/ │ │ │ │ │ │ ├── Card.module.css │ │ │ │ │ │ ├── Card.stories.tsx │ │ │ │ │ │ ├── Card.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── CoverImage/ │ │ │ │ │ │ ├── CoverImage.styles.module.scss │ │ │ │ │ │ ├── CoverImage.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Dialog/ │ │ │ │ │ │ ├── Dialog.module.css │ │ │ │ │ │ ├── Dialog.stories.tsx │ │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── DropdownMenu/ │ │ │ │ │ │ ├── DropdownMenu.module.scss │ │ │ │ │ │ ├── DropdownMenu.stories.tsx │ │ │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── FormControlledTextField/ │ │ │ │ │ │ ├── FormControlledTextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Hashtag/ │ │ │ │ │ │ ├── Tag.module.css │ │ │ │ │ │ ├── Tag.stories.tsx │ │ │ │ │ │ ├── Tag.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── IconButton/ │ │ │ │ │ │ ├── IconButton.module.css │ │ │ │ │ │ ├── IconButton.stories.tsx │ │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageCropper/ │ │ │ │ │ │ ├── ImageCropper.module.css │ │ │ │ │ │ ├── ImageCropper.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ImageUploader/ │ │ │ │ │ │ ├── ImageUploader.module.css │ │ │ │ │ │ ├── ImageUploader.stories.tsx │ │ │ │ │ │ ├── ImageUploader.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── LanguageSwitcher/ │ │ │ │ │ │ ├── LanguageSwitcher.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Pagination/ │ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ │ ├── Pagination.stories.tsx │ │ │ │ │ │ ├── Pagination.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Progress/ │ │ │ │ │ │ ├── Progress.module.css │ │ │ │ │ │ ├── Progress.stories.tsx │ │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── ReactionButtons/ │ │ │ │ │ │ ├── ReactionButtons.module.css │ │ │ │ │ │ ├── ReactionButtons.stories.tsx │ │ │ │ │ │ ├── ReactionButtons.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SearchField/ │ │ │ │ │ │ ├── SearchField.module.css │ │ │ │ │ │ ├── SearchField.stories.tsx │ │ │ │ │ │ ├── SearchField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Select/ │ │ │ │ │ │ ├── Select.module.css │ │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ │ ├── Select.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Skeleton/ │ │ │ │ │ │ ├── Skeleton.module.css │ │ │ │ │ │ ├── Skeleton.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── SortSelect/ │ │ │ │ │ │ └── Select.tsx │ │ │ │ │ ├── Spinner/ │ │ │ │ │ │ ├── Spinner.stories.tsx │ │ │ │ │ │ ├── Spinner.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── spinner.module.css │ │ │ │ │ ├── Table/ │ │ │ │ │ │ ├── Table.module.css │ │ │ │ │ │ ├── Table.stories.tsx │ │ │ │ │ │ ├── Table.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Tabs/ │ │ │ │ │ │ ├── Tabs.module.css │ │ │ │ │ │ ├── Tabs.stories.tsx │ │ │ │ │ │ ├── Tabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TagEditor/ │ │ │ │ │ │ ├── TagEditor.module.css │ │ │ │ │ │ ├── TagEditor.stories.tsx │ │ │ │ │ │ ├── TagEditor.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TextField/ │ │ │ │ │ │ ├── TextField.module.css │ │ │ │ │ │ ├── TextField.stories.tsx │ │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Textarea/ │ │ │ │ │ │ ├── Textarea.module.css │ │ │ │ │ │ ├── Textarea.stories.tsx │ │ │ │ │ │ ├── Textarea.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Typography/ │ │ │ │ │ │ ├── Typography.module.css │ │ │ │ │ │ ├── Typography.stories.tsx │ │ │ │ │ │ ├── Typography.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── config/ │ │ │ │ │ ├── config.ts │ │ │ │ │ └── paths.ts │ │ │ │ ├── featureFlags.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── debounceCallback/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useDebounceCallback.ts │ │ │ │ │ │ └── useDebounceCallback.types.ts │ │ │ │ │ ├── debounceValue/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── useDebounceValue.ts │ │ │ │ │ ├── getId/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── useGetId.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── throttleCallback/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useThrottleCallback.tsx │ │ │ │ │ │ └── useThrottleCallback.types.ts │ │ │ │ │ ├── useCurrentPage.ts │ │ │ │ │ ├── useDeletePlaylistAction.ts │ │ │ │ │ ├── useEntityReactions.ts │ │ │ │ │ ├── useHover.ts │ │ │ │ │ ├── usePageBackgroundColor.ts │ │ │ │ │ └── usePageSearchParams.ts │ │ │ │ ├── icons/ │ │ │ │ │ ├── AddToPlaylistIcon.tsx │ │ │ │ │ ├── ArrowBackIcon.tsx │ │ │ │ │ ├── ArrowDownIcon.tsx │ │ │ │ │ ├── CheckedIcon.tsx │ │ │ │ │ ├── ClockIcon.tsx │ │ │ │ │ ├── CreateIcon.tsx │ │ │ │ │ ├── DeleteIcon.tsx │ │ │ │ │ ├── DeleteTagIconButton.tsx │ │ │ │ │ ├── DislikeIcon.tsx │ │ │ │ │ ├── DownloadIcon.tsx │ │ │ │ │ ├── EditIcon.tsx │ │ │ │ │ ├── HomeIcon.tsx │ │ │ │ │ ├── ImageUploadIcon.tsx │ │ │ │ │ ├── KeyboardArrowLeftIcon.tsx │ │ │ │ │ ├── KeyboardArrowRightIcon.tsx │ │ │ │ │ ├── LanguageIcon.tsx │ │ │ │ │ ├── LibraryIcon.tsx │ │ │ │ │ ├── LikeIcon.tsx │ │ │ │ │ ├── LikeIconFill.tsx │ │ │ │ │ ├── LikeInSquareIcon.tsx │ │ │ │ │ ├── LiveWaveIcon/ │ │ │ │ │ │ ├── LiveWaveIcon.module.css │ │ │ │ │ │ ├── LiveWaveIcon.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── LogoutIcon.tsx │ │ │ │ │ ├── MoreIcon.tsx │ │ │ │ │ ├── PauseIcon.tsx │ │ │ │ │ ├── PlayIcon.tsx │ │ │ │ │ ├── PlaylistIcon.tsx │ │ │ │ │ ├── PlusIcon.tsx │ │ │ │ │ ├── ProfileIcon.tsx │ │ │ │ │ ├── RepeatIcon.tsx │ │ │ │ │ ├── SearchIcon.tsx │ │ │ │ │ ├── ShuffleIcon.tsx │ │ │ │ │ ├── SkipNextIcon.tsx │ │ │ │ │ ├── SkipPreviousIcon.tsx │ │ │ │ │ ├── TextIcon.tsx │ │ │ │ │ ├── TrackIcon.tsx │ │ │ │ │ ├── UncheckedIcon.tsx │ │ │ │ │ ├── UploadIcon.tsx │ │ │ │ │ ├── VolumeIcon.tsx │ │ │ │ │ ├── VolumeMuteIcon.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── model/ │ │ │ │ │ └── ui-store.ts │ │ │ │ ├── translations/ │ │ │ │ │ ├── i18nConfiguration.ts │ │ │ │ │ └── languages/ │ │ │ │ │ ├── en.json │ │ │ │ │ └── ru.json │ │ │ │ ├── types/ │ │ │ │ │ ├── api-track.types.ts │ │ │ │ │ └── strict.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── prerender-ready.tsx │ │ │ │ │ └── utils/ │ │ │ │ │ └── query-error-handler-for-rhf-factory.ts │ │ │ │ └── utils/ │ │ │ │ ├── authStorage.ts │ │ │ │ ├── decode-file-from-base-64.ts │ │ │ │ ├── format-created-date.ts │ │ │ │ ├── get-artist-id.ts │ │ │ │ ├── get-artist-name.ts │ │ │ │ ├── get-artists-by-track.ts │ │ │ │ ├── get-audio-url.ts │ │ │ │ ├── get-cover-url.ts │ │ │ │ ├── get-image-by-type.ts │ │ │ │ ├── get-plural-key.ts │ │ │ │ ├── get-russian-plural-form.ts │ │ │ │ ├── get-user-initials.ts │ │ │ │ ├── index.ts │ │ │ │ ├── join-url.test.ts │ │ │ │ ├── join-url.ts │ │ │ │ ├── set-locale.ts │ │ │ │ └── validators/ │ │ │ │ ├── getType.ts │ │ │ │ ├── inNun.ts │ │ │ │ ├── index.ts │ │ │ │ ├── isArray.ts │ │ │ │ ├── isFunction.ts │ │ │ │ ├── isNotEmptyArray.ts │ │ │ │ ├── isNull.ts │ │ │ │ ├── isNumber.ts │ │ │ │ ├── isObject.ts │ │ │ │ ├── isString.ts │ │ │ │ ├── isUndefined.ts │ │ │ │ ├── isValid.ts │ │ │ │ ├── isValidNumber.ts │ │ │ │ ├── isValidObject.ts │ │ │ │ └── isValidString.ts │ │ │ └── widgets/ │ │ │ └── Player/ │ │ │ ├── Player.module.css │ │ │ ├── Player.tsx │ │ │ └── index.ts │ │ ├── stylelint.config.js │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── ui-vanilla/ │ ├── .gitignore │ ├── .storybook/ │ │ ├── main.ts │ │ └── preview.tsx │ ├── README.md │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── app/ │ │ │ ├── App.tsx │ │ │ └── routing/ │ │ │ ├── Routing.tsx │ │ │ └── index.ts │ │ ├── features/ │ │ │ ├── artists/ │ │ │ │ ├── api/ │ │ │ │ │ ├── artists-api.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ └── ArtistCard/ │ │ │ │ ├── ArtistCard.module.css │ │ │ │ ├── ArtistCard.stories.tsx │ │ │ │ ├── ArtistCard.tsx │ │ │ │ └── index.ts │ │ │ ├── auth/ │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── LoginButtonAndModal/ │ │ │ │ │ ├── LoginButtonAndModal.module.css │ │ │ │ │ ├── LoginButtonAndModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ProfileDropdownMenu/ │ │ │ │ │ ├── ProfileDropdownMenu.module.css │ │ │ │ │ ├── ProfileDropdownMenu.stories.tsx │ │ │ │ │ ├── ProfileDropdownMenu.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── playlists/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── playlistsApi.ts │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── CreatePlaylistModal/ │ │ │ │ │ ├── CreatePlaylistModal.module.css │ │ │ │ │ ├── CreatePlaylistModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistCard/ │ │ │ │ │ ├── PlaylistCard.module.css │ │ │ │ │ ├── PlaylistCard.stories.tsx │ │ │ │ │ ├── PlaylistCard.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PlaylistOverview/ │ │ │ │ │ ├── PlaylistOverview.module.css │ │ │ │ │ ├── PlaylistOverview.stories.tsx │ │ │ │ │ ├── PlaylistOverview.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── tags/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tags-api.ts │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── TagsList/ │ │ │ │ │ ├── TagsList.module.css │ │ │ │ │ ├── TagsList.stories.tsx │ │ │ │ │ ├── TagsList.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ └── tracks/ │ │ │ ├── api/ │ │ │ │ ├── index.ts │ │ │ │ ├── tracksApi.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── ui/ │ │ │ ├── TrackCard/ │ │ │ │ ├── TrackCard.module.css │ │ │ │ ├── TrackCard.stories.tsx │ │ │ │ ├── TrackCard.tsx │ │ │ │ └── index.ts │ │ │ ├── TrackInfoCell/ │ │ │ │ ├── TrackInfoCell.module.css │ │ │ │ ├── TrackInfoCell.tsx │ │ │ │ └── index.ts │ │ │ ├── TrackOverview/ │ │ │ │ ├── TrackOverview.module.css │ │ │ │ ├── TrackOverview.stories.tsx │ │ │ │ ├── TrackOverview.tsx │ │ │ │ └── index.ts │ │ │ ├── TrackRow/ │ │ │ │ ├── TrackRow.module.css │ │ │ │ └── TrackRow.tsx │ │ │ ├── TracksTable/ │ │ │ │ ├── TrackTable.stories.tsx │ │ │ │ ├── TracksTable.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── layout/ │ │ │ ├── Header/ │ │ │ │ ├── Header.module.css │ │ │ │ ├── Header.tsx │ │ │ │ └── index.ts │ │ │ ├── Layout.module.css │ │ │ ├── Layout.tsx │ │ │ ├── Sidebar/ │ │ │ │ ├── MenuLinks/ │ │ │ │ │ ├── MenuLinks.module.css │ │ │ │ │ ├── MenuLinks.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Sidebar.module.css │ │ │ │ ├── Sidebar.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── main.tsx │ │ ├── pages/ │ │ │ ├── MainPage/ │ │ │ │ ├── MainPage.module.css │ │ │ │ ├── MainPage.tsx │ │ │ │ └── index.ts │ │ │ ├── PlaylistPage/ │ │ │ │ ├── PlaylistPage.module.css │ │ │ │ ├── PlaylistPage.tsx │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ └── ControlPanel/ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ ├── ControlPanel.tsx │ │ │ │ └── index.ts │ │ │ ├── PlaylistsPage/ │ │ │ │ ├── PlaylistsPage.module.css │ │ │ │ ├── PlaylistsPage.tsx │ │ │ │ └── index.ts │ │ │ ├── TrackPage/ │ │ │ │ ├── TrackPage.module.css │ │ │ │ ├── TrackPage.tsx │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ └── ControlPanel/ │ │ │ │ ├── ControlPanel.module.css │ │ │ │ ├── ControlPanel.tsx │ │ │ │ └── index.ts │ │ │ ├── TracksPage/ │ │ │ │ ├── TracksPage.module.css │ │ │ │ ├── TracksPage.tsx │ │ │ │ └── index.ts │ │ │ ├── UserPage/ │ │ │ │ ├── UserPage.module.css │ │ │ │ ├── UserPage.tsx │ │ │ │ ├── index.ts │ │ │ │ └── ui/ │ │ │ │ ├── UserInfo/ │ │ │ │ │ ├── UserInfo.module.css │ │ │ │ │ ├── UserInfo.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── UserTabs/ │ │ │ │ │ ├── LikedTracksTab/ │ │ │ │ │ │ ├── LikedTracksTab.module.css │ │ │ │ │ │ ├── LikedTracksTab.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── MyLikedPlaylistsTab/ │ │ │ │ │ │ ├── MyLikedPlaylistsTab.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── PlaylistsTab/ │ │ │ │ │ │ ├── PlaylistsTab.module.css │ │ │ │ │ │ ├── PlaylistsTab.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── TracksTab/ │ │ │ │ │ │ ├── TracksTab.module.css │ │ │ │ │ │ ├── TracksTab.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── UserTabs.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── common/ │ │ │ │ ├── ContentList/ │ │ │ │ │ ├── ContentList.module.css │ │ │ │ │ ├── ContentList.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── PageWrapper/ │ │ │ │ │ ├── PageWrapper.module.css │ │ │ │ │ ├── PageWrapper.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── SearchTextField/ │ │ │ │ │ ├── SearchTextField.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── SortSelect/ │ │ │ │ │ ├── SortSelect.module.css │ │ │ │ │ ├── SortSelect.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── shared/ │ │ │ ├── components/ │ │ │ │ ├── AudioPlayer/ │ │ │ │ │ ├── AudioPlayer.module.css │ │ │ │ │ ├── AudioPlayer.stories.tsx │ │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Autocomplete/ │ │ │ │ │ ├── Autocomplete.module.css │ │ │ │ │ ├── Autocomplete.stories.tsx │ │ │ │ │ ├── Autocomplete.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Button/ │ │ │ │ │ ├── Button.module.css │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ ├── Button.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Card/ │ │ │ │ │ ├── Card.module.css │ │ │ │ │ ├── Card.stories.tsx │ │ │ │ │ ├── Card.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Dialog/ │ │ │ │ │ ├── Dialog.module.css │ │ │ │ │ ├── Dialog.stories.tsx │ │ │ │ │ ├── Dialog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── DropdownMenu/ │ │ │ │ │ ├── DropdownMenu.module.css │ │ │ │ │ ├── DropdownMenu.stories.tsx │ │ │ │ │ ├── DropdownMenu.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Hashtag/ │ │ │ │ │ ├── Tag.module.css │ │ │ │ │ ├── Tag.stories.tsx │ │ │ │ │ ├── Tag.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── IconButton/ │ │ │ │ │ ├── IconButton.module.css │ │ │ │ │ ├── IconButton.stories.tsx │ │ │ │ │ ├── IconButton.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ImageUploader/ │ │ │ │ │ ├── ImageUploader.module.css │ │ │ │ │ ├── ImageUploader.stories.tsx │ │ │ │ │ ├── ImageUploader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Pagination/ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ ├── Pagination.stories.tsx │ │ │ │ │ ├── Pagination.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Progress/ │ │ │ │ │ ├── Progress.module.css │ │ │ │ │ ├── Progress.stories.tsx │ │ │ │ │ ├── Progress.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ReactionButtons/ │ │ │ │ │ ├── ReactionButtons.module.css │ │ │ │ │ ├── ReactionButtons.stories.tsx │ │ │ │ │ ├── ReactionButtons.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── SearchField/ │ │ │ │ │ ├── SearchField.module.css │ │ │ │ │ ├── SearchField.stories.tsx │ │ │ │ │ ├── SearchField.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Select/ │ │ │ │ │ ├── Select.module.css │ │ │ │ │ ├── Select.stories.tsx │ │ │ │ │ ├── Select.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── SortSelect/ │ │ │ │ │ └── Select.tsx │ │ │ │ ├── Table/ │ │ │ │ │ ├── Table.module.css │ │ │ │ │ ├── Table.stories.tsx │ │ │ │ │ ├── Table.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Tabs/ │ │ │ │ │ ├── Tabs.module.css │ │ │ │ │ ├── Tabs.stories.tsx │ │ │ │ │ ├── Tabs.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TagEditor/ │ │ │ │ │ ├── TagEditor.module.css │ │ │ │ │ ├── TagEditor.stories.tsx │ │ │ │ │ ├── TagEditor.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── TextField/ │ │ │ │ │ ├── TextField.module.css │ │ │ │ │ ├── TextField.stories.tsx │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Textarea/ │ │ │ │ │ ├── Textarea.module.css │ │ │ │ │ ├── Textarea.stories.tsx │ │ │ │ │ ├── Textarea.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Typography/ │ │ │ │ │ ├── Typography.module.css │ │ │ │ │ ├── Typography.stories.tsx │ │ │ │ │ ├── Typography.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ └── useGetId.ts │ │ │ └── icons/ │ │ │ ├── AddToPlaylistIcon.tsx │ │ │ ├── ArrowDownIcon.tsx │ │ │ ├── ClockIcon.tsx │ │ │ ├── CreateIcon.tsx │ │ │ ├── DeleteIcon.tsx │ │ │ ├── DislikeIcon.tsx │ │ │ ├── DownloadIcon.tsx │ │ │ ├── EditIcon.tsx │ │ │ ├── HomeIcon.tsx │ │ │ ├── ImageUploadIcon.tsx │ │ │ ├── KeyboardArrowLeftIcon.tsx │ │ │ ├── KeyboardArrowRightIcon.tsx │ │ │ ├── LibraryIcon.tsx │ │ │ ├── LikeIcon.tsx │ │ │ ├── LikeIconFill.tsx │ │ │ ├── LikeInSquareIcon.tsx │ │ │ ├── LiveWaveIcon/ │ │ │ │ ├── LiveWaveIcon.module.css │ │ │ │ ├── LiveWaveIcon.tsx │ │ │ │ └── index.ts │ │ │ ├── LogoutIcon.tsx │ │ │ ├── MoreIcon.tsx │ │ │ ├── PauseIcon.tsx │ │ │ ├── PlayIcon.tsx │ │ │ ├── PlaylistIcon.tsx │ │ │ ├── PlusIcon.tsx │ │ │ ├── ProfileIcon.tsx │ │ │ ├── RepeatIcon.tsx │ │ │ ├── SearchIcon.tsx │ │ │ ├── ShuffleIcon.tsx │ │ │ ├── SkipNextIcon.tsx │ │ │ ├── SkipPreviousIcon.tsx │ │ │ ├── TextIcon.tsx │ │ │ ├── TrackIcon.tsx │ │ │ ├── UploadIcon.tsx │ │ │ ├── VolumeIcon.tsx │ │ │ ├── VolumeMuteIcon.tsx │ │ │ └── index.ts │ │ ├── styles/ │ │ │ ├── fonts.css │ │ │ ├── global.css │ │ │ ├── reset.css │ │ │ └── variables.css │ │ ├── vite-env.d.ts │ │ └── widgets/ │ │ └── Player/ │ │ ├── Player.module.css │ │ ├── Player.tsx │ │ └── index.ts │ ├── stylelint.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── architecture/ │ └── microfrontends/ │ ├── player/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── index.css │ │ │ └── main.tsx │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── vite.config.readme.md │ │ └── vite.config.ts │ └── root/ │ ├── .gitignore │ ├── README.md │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── index.css │ │ └── main.tsx │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── content-thoughts/ │ └── search-input/ │ └── info.md ├── docs/ │ ├── feature-comparison.md │ └── todos-features.md ├── eslint.config.js ├── experiment-apps/ │ ├── musicfun-tanstack-query-orval-small-example/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── orval.config.cjs │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── entrypoint/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ ├── root-layout.module.css │ │ │ │ │ └── root-layout.tsx │ │ │ │ ├── providers/ │ │ │ │ │ └── web-socket-provider.tsx │ │ │ │ ├── query-client/ │ │ │ │ │ └── query-client.tsx │ │ │ │ ├── routes/ │ │ │ │ │ ├── __root.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── my-playlists.tsx │ │ │ │ │ ├── oauth/ │ │ │ │ │ │ └── callback.tsx │ │ │ │ │ └── playlists-with-filters.tsx │ │ │ │ └── styles/ │ │ │ │ ├── index.css │ │ │ │ └── reset.css │ │ │ ├── features/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── auth-api.types.ts │ │ │ │ │ │ ├── use-login.mutation.ts │ │ │ │ │ │ ├── use-logout.mutation.ts │ │ │ │ │ │ └── use-me.query.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── ui/ │ │ │ │ │ ├── account-bar.module.css │ │ │ │ │ ├── account-bar.tsx │ │ │ │ │ ├── current-user/ │ │ │ │ │ │ └── current-user.tsx │ │ │ │ │ ├── login-button/ │ │ │ │ │ │ ├── login-button.tsx │ │ │ │ │ │ └── use-login.tsx │ │ │ │ │ └── logout-button/ │ │ │ │ │ ├── logout-button.tsx │ │ │ │ │ └── use-logout.ts │ │ │ │ └── playlists/ │ │ │ │ ├── add-playlist-form/ │ │ │ │ │ ├── add-playlist-form.module.css │ │ │ │ │ └── add-playlist-form.tsx │ │ │ │ ├── api/ │ │ │ │ │ └── use-playlists-query.tsx │ │ │ │ ├── edit-playlist-form/ │ │ │ │ │ └── edit-playlist-form.tsx │ │ │ │ ├── list/ │ │ │ │ │ ├── paginated-playlists.module.css │ │ │ │ │ ├── paginated-playlists.tsx │ │ │ │ │ └── playlists.tsx │ │ │ │ └── playlist-cover/ │ │ │ │ ├── playlist-cover.module.css │ │ │ │ └── playlist-cover.tsx │ │ │ ├── pages/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── ui/ │ │ │ │ │ └── oauth-callback-page.tsx │ │ │ │ └── playlists/ │ │ │ │ └── ui/ │ │ │ │ ├── my-playlists/ │ │ │ │ │ ├── my-playlists-page.module.css │ │ │ │ │ └── my-playlists-page.tsx │ │ │ │ └── playlists-with-filters-page.tsx │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-api-error.ts │ │ │ │ │ ├── orval/ │ │ │ │ │ │ ├── artists/ │ │ │ │ │ │ │ └── artists.ts │ │ │ │ │ │ ├── authentication/ │ │ │ │ │ │ │ └── authentication.ts │ │ │ │ │ │ ├── custom-instance.ts │ │ │ │ │ │ ├── musicfun.schemas.ts │ │ │ │ │ │ ├── musicfun.ts │ │ │ │ │ │ ├── playlists-owner/ │ │ │ │ │ │ │ └── playlists-owner.ts │ │ │ │ │ │ ├── playlists-public/ │ │ │ │ │ │ │ └── playlists-public.ts │ │ │ │ │ │ ├── tags/ │ │ │ │ │ │ │ └── tags.ts │ │ │ │ │ │ ├── tracks-owner/ │ │ │ │ │ │ │ └── tracks-owner.ts │ │ │ │ │ │ └── tracks-public/ │ │ │ │ │ │ └── tracks-public.ts │ │ │ │ │ ├── query-error-handler-for-rhf-factory.ts │ │ │ │ │ ├── request-wrapper.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── socket.ts │ │ │ │ ├── config/ │ │ │ │ │ └── api.config.ts │ │ │ │ ├── db/ │ │ │ │ │ └── localstorage-keys.ts │ │ │ │ ├── routes/ │ │ │ │ │ └── routes.ts │ │ │ │ └── ui/ │ │ │ │ ├── header/ │ │ │ │ │ ├── header.component.tsx │ │ │ │ │ └── header.module.css │ │ │ │ └── pagination/ │ │ │ │ ├── pagination-nav/ │ │ │ │ │ ├── pagination-nav.module.css │ │ │ │ │ └── pagination-nav.tsx │ │ │ │ ├── pagination.module.css │ │ │ │ ├── pagination.tsx │ │ │ │ └── utils/ │ │ │ │ └── get-pagination-pages.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsr.config.json │ │ └── vite.config.ts │ ├── musicfun-tanstack-query-small-example/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── entrypoint/ │ │ │ │ │ └── main.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ ├── root-layout.module.css │ │ │ │ │ └── root-layout.tsx │ │ │ │ ├── providers/ │ │ │ │ │ └── web-socket-provider.tsx │ │ │ │ ├── query-client/ │ │ │ │ │ └── query-client.tsx │ │ │ │ ├── routes/ │ │ │ │ │ ├── __root.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── my-playlists.tsx │ │ │ │ │ ├── oauth/ │ │ │ │ │ │ └── callback.tsx │ │ │ │ │ └── playlists-with-filters.tsx │ │ │ │ └── styles/ │ │ │ │ ├── index.css │ │ │ │ └── reset.css │ │ │ ├── features/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── auth-api.types.ts │ │ │ │ │ │ ├── use-login.mutation.ts │ │ │ │ │ │ ├── use-logout.mutation.ts │ │ │ │ │ │ └── use-me.query.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── ui/ │ │ │ │ │ ├── account-bar.module.css │ │ │ │ │ ├── account-bar.tsx │ │ │ │ │ ├── current-user/ │ │ │ │ │ │ └── current-user.tsx │ │ │ │ │ ├── login-button/ │ │ │ │ │ │ ├── login-button.tsx │ │ │ │ │ │ └── use-login.tsx │ │ │ │ │ └── logout-button/ │ │ │ │ │ ├── logout-button.tsx │ │ │ │ │ └── use-logout.ts │ │ │ │ └── playlists/ │ │ │ │ ├── add-playlist-form/ │ │ │ │ │ ├── add-playlist-form.module.css │ │ │ │ │ └── add-playlist-form.tsx │ │ │ │ ├── api/ │ │ │ │ │ └── use-playlists-query.tsx │ │ │ │ ├── edit-playlist-form/ │ │ │ │ │ └── edit-playlist-form.tsx │ │ │ │ ├── list/ │ │ │ │ │ ├── paginated-playlists.module.css │ │ │ │ │ ├── paginated-playlists.tsx │ │ │ │ │ └── playlists.tsx │ │ │ │ └── playlist-cover/ │ │ │ │ ├── playlist-cover.module.css │ │ │ │ └── playlist-cover.tsx │ │ │ ├── pages/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── ui/ │ │ │ │ │ └── oauth-callback-page.tsx │ │ │ │ └── playlists/ │ │ │ │ └── ui/ │ │ │ │ ├── my-playlists/ │ │ │ │ │ ├── my-playlists-page.module.css │ │ │ │ │ └── my-playlists-page.tsx │ │ │ │ └── playlists-with-filters-page.tsx │ │ │ ├── shared/ │ │ │ │ ├── api/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-api-error.ts │ │ │ │ │ ├── query-error-handler-for-rhf-factory.ts │ │ │ │ │ ├── request-wrapper.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── socket.ts │ │ │ │ ├── config/ │ │ │ │ │ └── api.config.ts │ │ │ │ ├── db/ │ │ │ │ │ └── localstorage-keys.ts │ │ │ │ ├── routes/ │ │ │ │ │ └── routes.ts │ │ │ │ └── ui/ │ │ │ │ ├── header/ │ │ │ │ │ ├── header.component.tsx │ │ │ │ │ └── header.module.css │ │ │ │ └── pagination/ │ │ │ │ ├── pagination-nav/ │ │ │ │ │ ├── pagination-nav.module.css │ │ │ │ │ └── pagination-nav.tsx │ │ │ │ ├── pagination.module.css │ │ │ │ ├── pagination.tsx │ │ │ │ └── utils/ │ │ │ │ └── get-pagination-pages.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ ├── tsr.config.json │ │ └── vite.config.ts │ └── trelly-rtk/ │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── index.html │ ├── openapi-config.js │ ├── package.json │ ├── src/ │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── baseApi.ts │ │ │ │ ├── baseQuery.ts │ │ │ │ └── baseQueryWithReauth.ts │ │ │ ├── model/ │ │ │ │ ├── app-slice.ts │ │ │ │ └── store.ts │ │ │ └── ui/ │ │ │ ├── App.module.css │ │ │ ├── App.tsx │ │ │ └── Main.tsx │ │ ├── common/ │ │ │ ├── actions/ │ │ │ │ ├── actions.ts │ │ │ │ └── index.ts │ │ │ ├── components/ │ │ │ │ ├── CreateItemForm/ │ │ │ │ │ └── CreateItemForm.tsx │ │ │ │ ├── EditableSpan/ │ │ │ │ │ └── EditableSpan.tsx │ │ │ │ ├── ErrorSnackbar/ │ │ │ │ │ └── ErrorSnackbar.tsx │ │ │ │ ├── Header/ │ │ │ │ │ └── Header.tsx │ │ │ │ ├── NavButton/ │ │ │ │ │ └── NavButton.ts │ │ │ │ ├── PageNotFound/ │ │ │ │ │ ├── PageNotFound.module.css │ │ │ │ │ └── PageNotFound.tsx │ │ │ │ ├── ProtectedRoute/ │ │ │ │ │ └── ProtectedRoute.tsx │ │ │ │ └── index.ts │ │ │ ├── constants/ │ │ │ │ ├── constants.ts │ │ │ │ └── index.ts │ │ │ ├── enums/ │ │ │ │ ├── enums.ts │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── useAppDispatch.ts │ │ │ │ └── useAppSelector.ts │ │ │ ├── instance/ │ │ │ │ ├── index.ts │ │ │ │ └── instance.ts │ │ │ ├── routing/ │ │ │ │ ├── Routing.tsx │ │ │ │ └── index.ts │ │ │ ├── styles/ │ │ │ │ ├── container.styles.ts │ │ │ │ └── index.ts │ │ │ ├── theme/ │ │ │ │ ├── index.ts │ │ │ │ └── theme.ts │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── utils/ │ │ │ ├── createAppSlice.ts │ │ │ ├── handleError.ts │ │ │ ├── index.ts │ │ │ ├── isErrorWithMessage.ts │ │ │ └── isTokens.ts │ │ ├── features/ │ │ │ ├── auth/ │ │ │ │ ├── api/ │ │ │ │ │ ├── authApi.ts │ │ │ │ │ └── authApi.types.ts │ │ │ │ ├── lib/ │ │ │ │ │ └── schemas/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── loginSchema.ts │ │ │ │ └── ui/ │ │ │ │ ├── Login/ │ │ │ │ │ ├── Login.module.css │ │ │ │ │ └── Login.tsx │ │ │ │ ├── OAuthCallback/ │ │ │ │ │ └── OAuthCallback.tsx │ │ │ │ └── UserBlock/ │ │ │ │ └── UserBlock.tsx │ │ │ ├── boards/ │ │ │ │ ├── api/ │ │ │ │ │ ├── boardsApi.ts │ │ │ │ │ └── boardsApi.types.ts │ │ │ │ ├── lib/ │ │ │ │ │ └── utils/ │ │ │ │ │ ├── createTaskModel.ts │ │ │ │ │ └── index.ts │ │ │ │ └── ui/ │ │ │ │ └── Boards/ │ │ │ │ ├── BoardItem/ │ │ │ │ │ ├── BoardItem.tsx │ │ │ │ │ ├── BoardTitle/ │ │ │ │ │ │ ├── BoardTitle.module.css │ │ │ │ │ │ └── BoardTitle.tsx │ │ │ │ │ └── FilterButtons/ │ │ │ │ │ └── FilterButtons.tsx │ │ │ │ ├── BoardSkeleton/ │ │ │ │ │ ├── BoardSkeleton.module.css │ │ │ │ │ └── BoardSkeleton.tsx │ │ │ │ └── Boards.tsx │ │ │ └── tasks/ │ │ │ ├── api/ │ │ │ │ ├── tasksApi.ts │ │ │ │ └── tasksApi.types.ts │ │ │ └── ui/ │ │ │ └── Tasks/ │ │ │ ├── TaskItem/ │ │ │ │ ├── TaskItem.styles.ts │ │ │ │ └── TaskItem.tsx │ │ │ ├── Tasks.tsx │ │ │ ├── TasksPagination/ │ │ │ │ ├── TasksPagination.module.css │ │ │ │ └── TasksPagination.tsx │ │ │ └── TasksSkeleton/ │ │ │ └── TasksSkeleton.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package.json ├── packages/ │ └── musicfun-api-sdk/ │ ├── package.json │ ├── src/ │ │ ├── api/ │ │ │ ├── artists/ │ │ │ │ ├── artistsApi.ts │ │ │ │ └── artistsApi.types.ts │ │ │ ├── auth/ │ │ │ │ ├── authApi.ts │ │ │ │ └── authApi.types.ts │ │ │ ├── playlists/ │ │ │ │ ├── playlistsApi.ts │ │ │ │ └── playlistsApi.types.ts │ │ │ ├── tags/ │ │ │ │ ├── tagsApi.ts │ │ │ │ └── tagsApi.types.ts │ │ │ └── tracks/ │ │ │ ├── tracksApi.ts │ │ │ └── tracksApi.types.ts │ │ ├── common/ │ │ │ ├── apiEntities/ │ │ │ │ └── apiEntities.ts │ │ │ ├── instance/ │ │ │ │ └── instance.ts │ │ │ ├── types/ │ │ │ │ ├── common.types.ts │ │ │ │ ├── enums.ts │ │ │ │ └── playlists-tracks.types.ts │ │ │ └── utils/ │ │ │ └── urlHelper.ts │ │ ├── index.ts │ │ └── v2/ │ │ └── request.ts │ └── tsconfig.json ├── public/ │ ├── 404.html │ └── index.html ├── type-comparison-examples.md └── youtube/ ├── markup/ │ ├── .gitignore │ ├── README.md │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── rtk-query/ │ └── lesson1/ │ ├── .gitignore │ ├── .prettierrc │ ├── AGENTS.md │ ├── README.md │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ ├── baseApi.ts │ │ │ │ ├── baseQuery.ts │ │ │ │ └── baseQueryWithReauth.ts │ │ │ ├── model/ │ │ │ │ └── store.ts │ │ │ └── ui/ │ │ │ ├── App/ │ │ │ │ ├── App.module.css │ │ │ │ └── App.tsx │ │ │ └── MainPage/ │ │ │ └── MainPage.tsx │ │ ├── common/ │ │ │ ├── components/ │ │ │ │ ├── Header/ │ │ │ │ │ ├── Header.module.css │ │ │ │ │ └── Header.tsx │ │ │ │ ├── LinearProgress/ │ │ │ │ │ ├── LinearProgress.module.css │ │ │ │ │ └── LinearProgress.tsx │ │ │ │ ├── PageNotFound/ │ │ │ │ │ ├── PageNotFound.module.css │ │ │ │ │ └── PageNotFound.tsx │ │ │ │ ├── Pagination/ │ │ │ │ │ ├── Pagination.module.css │ │ │ │ │ └── Pagination.tsx │ │ │ │ └── index.tsx │ │ │ ├── constants/ │ │ │ │ ├── constants.ts │ │ │ │ └── index.ts │ │ │ ├── enums/ │ │ │ │ ├── enums.ts │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── useDebounceValue.ts │ │ │ │ ├── useGlobalLoading.ts │ │ │ │ └── useInfiniteScroll.ts │ │ │ ├── routing/ │ │ │ │ ├── Routing.tsx │ │ │ │ └── index.ts │ │ │ ├── schemas/ │ │ │ │ ├── index.ts │ │ │ │ └── schemas.ts │ │ │ ├── socket/ │ │ │ │ ├── getSocket.ts │ │ │ │ ├── index.ts │ │ │ │ └── subscribeToEvent.ts │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── utils/ │ │ │ ├── errorToast.ts │ │ │ ├── getPaginationPages.ts │ │ │ ├── handleErrors.ts │ │ │ ├── index.ts │ │ │ ├── isErrorWithDetailArray.ts │ │ │ ├── isErrorWithProperty.ts │ │ │ ├── isTokens.ts │ │ │ ├── trimToMaxLength.ts │ │ │ └── withZodCatch.ts │ │ ├── features/ │ │ │ ├── auth/ │ │ │ │ ├── api/ │ │ │ │ │ ├── authApi.ts │ │ │ │ │ └── authApi.types.ts │ │ │ │ ├── model/ │ │ │ │ │ └── auth.schemas.ts │ │ │ │ └── ui/ │ │ │ │ ├── Login/ │ │ │ │ │ └── Login.tsx │ │ │ │ ├── OAuthCallback/ │ │ │ │ │ └── OAuthCallback.tsx │ │ │ │ └── ProfilePage/ │ │ │ │ ├── ProfilePage.module.css │ │ │ │ └── ProfilePage.tsx │ │ │ ├── playlists/ │ │ │ │ ├── api/ │ │ │ │ │ ├── playlistsApi.ts │ │ │ │ │ └── playlistsApi.types.ts │ │ │ │ ├── model/ │ │ │ │ │ └── playlists.schemas.ts │ │ │ │ └── ui/ │ │ │ │ ├── CreatePlaylistForm/ │ │ │ │ │ ├── CreatePlaylistForm.module.css │ │ │ │ │ └── CreatePlaylistForm.tsx │ │ │ │ ├── EditPlaylistForm/ │ │ │ │ │ └── EditPlaylistForm.tsx │ │ │ │ ├── PlaylistItem/ │ │ │ │ │ ├── PlaylistCover/ │ │ │ │ │ │ ├── PlaylistCover.module.css │ │ │ │ │ │ └── PlaylistCover.tsx │ │ │ │ │ ├── PlaylistDescription/ │ │ │ │ │ │ └── PlaylistDescription.tsx │ │ │ │ │ └── PlaylistItem.tsx │ │ │ │ ├── PlaylistsList/ │ │ │ │ │ ├── PlaylistsList.module.css │ │ │ │ │ └── PlaylistsList.tsx │ │ │ │ ├── PlaylistsPage.module.css │ │ │ │ └── PlaylistsPage.tsx │ │ │ └── tracks/ │ │ │ ├── api/ │ │ │ │ ├── tracksApi.ts │ │ │ │ └── tracksApi.types.ts │ │ │ ├── model/ │ │ │ │ └── tracks.schemas.ts │ │ │ └── ui/ │ │ │ ├── LoadingTrigger/ │ │ │ │ └── LoadingTrigger.tsx │ │ │ ├── TracksList/ │ │ │ │ ├── TracksList.module.css │ │ │ │ └── TracksList.tsx │ │ │ └── TracksPage.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── tanstack-query-router-fsd/ └── lesson1/ ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── src/ │ ├── app/ │ │ ├── entrypoint/ │ │ │ └── main.tsx │ │ ├── layouts/ │ │ │ ├── root-layout.module.css │ │ │ └── root-layout.tsx │ │ ├── routes/ │ │ │ ├── __root.tsx │ │ │ ├── index.tsx │ │ │ ├── my-playlists.tsx │ │ │ ├── oauth/ │ │ │ │ └── callback.tsx │ │ │ └── routeTree.gen.ts │ │ ├── styles/ │ │ │ ├── index.css │ │ │ └── reset.css │ │ ├── tanstack-query/ │ │ │ └── query-client-instance.tsx │ │ └── tanstack-router/ │ │ └── router-instance.tsx │ ├── features/ │ │ ├── auth/ │ │ │ ├── api/ │ │ │ │ ├── use-login-mutation.tsx │ │ │ │ ├── use-logout-mutation.tsx │ │ │ │ └── use-me-query.ts │ │ │ └── ui/ │ │ │ ├── account-bar.module.css │ │ │ ├── account-bar.tsx │ │ │ ├── current-user/ │ │ │ │ └── current-user.tsx │ │ │ ├── login-button.tsx │ │ │ └── logout-button.tsx │ │ └── playlists/ │ │ ├── add-playlist/ │ │ │ ├── api/ │ │ │ │ └── use-add-playlist-mutation.ts │ │ │ └── ui/ │ │ │ └── add-playlist-form.tsx │ │ ├── delete-playlist/ │ │ │ ├── api/ │ │ │ │ └── use-delete-mutation.ts │ │ │ └── ui/ │ │ │ └── delete-playlist.tsx │ │ └── edit-playlist/ │ │ ├── api/ │ │ │ ├── use-playlist-query.tsx │ │ │ └── use-update-playlist-mutation.ts │ │ └── ui/ │ │ └── edit-playlist-form.tsx │ ├── pages/ │ │ ├── auth/ │ │ │ └── oauth-callback-page.tsx │ │ ├── my-playlists-page.tsx │ │ └── playlists-page.tsx │ ├── shared/ │ │ ├── api/ │ │ │ ├── client.ts │ │ │ ├── keys-factories/ │ │ │ │ ├── auth-keys-factory.ts │ │ │ │ └── playlists-keys-factory.ts │ │ │ └── schema.ts │ │ ├── config/ │ │ │ ├── api-config.ts │ │ │ └── localstorage-keys.ts │ │ ├── ui/ │ │ │ ├── header/ │ │ │ │ ├── header.module.css │ │ │ │ └── header.tsx │ │ │ ├── pagination/ │ │ │ │ ├── pagination-nav/ │ │ │ │ │ ├── pagination-nav.module.css │ │ │ │ │ └── pagination-nav.tsx │ │ │ │ ├── pagination.module.css │ │ │ │ ├── pagination.tsx │ │ │ │ └── utils/ │ │ │ │ └── get-pagination-pages.ts │ │ │ └── util/ │ │ │ └── query-error-handler-for-rhf-factory.ts │ │ └── util/ │ │ └── json-api-error.ts │ ├── vite-env.d.ts │ └── widgets/ │ └── playlists/ │ ├── api/ │ │ └── use-playlists-query.ts │ └── ui/ │ └── playlists.tsx ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsr.config.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci-rtk.yml ================================================ name: CI - RTK Query Build on: push: branches: - develop paths: - 'apps/rtk-query/**' pull_request: paths: - 'apps/rtk-query/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/rtk-query run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/rtk-query run: pnpm build ================================================ FILE: .github/workflows/ci-tanstack.yml ================================================ name: CI - TanStack Query Build on: push: branches: - develop paths: - 'apps/tanstack-query-zustand/**' pull_request: paths: - 'apps/tanstack-query-zustand/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/tanstack-query-zustand run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/tanstack-query-zustand run: pnpm build ================================================ FILE: .github/workflows/deploy-effector.yml ================================================ name: Deploy Effector App on: workflow_dispatch: concurrency: group: gh-pages-deploy cancel-in-progress: false permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/react-effector-fsd run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/react-effector-fsd run: pnpm build - name: Create SPA fallback working-directory: apps/react-effector-fsd/dist run: cp index.html 404.html - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: apps/react-effector-fsd/dist target-folder: effector clean: false branch: gh-pages ================================================ FILE: .github/workflows/deploy-reatom.yml ================================================ name: Deploy Reatom App on: workflow_dispatch: concurrency: group: gh-pages-deploy cancel-in-progress: false permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/reatom run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/reatom run: pnpm build - name: Create SPA fallback working-directory: apps/reatom/dist run: cp index.html 404.html - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: apps/reatom/dist target-folder: reatom clean: false branch: gh-pages ================================================ FILE: .github/workflows/deploy-root.yml ================================================ name: Deploy Root Landing Page on: workflow_dispatch: concurrency: group: gh-pages-deploy cancel-in-progress: false permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Prepare root files run: | mkdir -p temp-root cp public/index.html temp-root/ cp public/404.html temp-root/ echo 'stg.musicfun.dev' > temp-root/CNAME touch temp-root/.nojekyll - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: temp-root target-folder: . clean: false clean-exclude: | tanstackquery/ rtkquery/ reatom/ effector/ branch: gh-pages ================================================ FILE: .github/workflows/deploy-rtk.yml ================================================ name: Deploy RTK Query App on: workflow_dispatch: concurrency: group: gh-pages-deploy cancel-in-progress: false permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/rtk-query run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/rtk-query run: pnpm build - name: Create SPA fallback working-directory: apps/rtk-query/dist run: cp index.html 404.html - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: apps/rtk-query/dist target-folder: rtkquery clean: false branch: gh-pages ================================================ FILE: .github/workflows/deploy-tanstack.yml ================================================ name: Deploy TanStack Query App on: workflow_dispatch: concurrency: group: gh-pages-deploy cancel-in-progress: false permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false - name: Install dependencies working-directory: apps/tanstack-query-zustand run: pnpm install --no-frozen-lockfile - name: Build app working-directory: apps/tanstack-query-zustand run: pnpm build - name: Create SPA fallback working-directory: apps/tanstack-query-zustand/dist run: cp index.html 404.html - name: Deploy to GitHub Pages uses: JamesIves/github-pages-deploy-action@v4 with: folder: apps/tanstack-query-zustand/dist target-folder: tanstackquery clean: false branch: gh-pages ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy MusicFun Apps to GitHub Pages on: workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages-deploy cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - uses: pnpm/action-setup@v4 with: run_install: false # === Install === - name: Install deps for tanstack-query (folder renamed) working-directory: apps/tanstack-query-zustand run: pnpm install --no-frozen-lockfile - name: Install deps for rtk-query working-directory: apps/rtk-query run: pnpm install --no-frozen-lockfile - name: Install deps for reatom working-directory: apps/reatom run: pnpm install --no-frozen-lockfile - name: Install deps for effector working-directory: apps/react-effector-fsd run: pnpm install --no-frozen-lockfile # === Build === - name: Build tanstack-query working-directory: apps/tanstack-query-zustand run: pnpm build - name: Build rtk-query working-directory: apps/rtk-query run: pnpm build - name: Build reatom working-directory: apps/reatom run: pnpm build - name: Build effector working-directory: apps/react-effector-fsd run: pnpm build # === Pages config === - uses: actions/configure-pages@v5 with: enablement: true # === SPA fallback === - name: SPA fallback for tanstack-query working-directory: apps/tanstack-query-zustand/dist run: cp index.html 404.html - name: SPA fallback for rtk-query working-directory: apps/rtk-query/dist run: cp index.html 404.html - name: SPA fallback for reatom working-directory: apps/reatom/dist run: cp index.html 404.html - name: SPA fallback for effector working-directory: apps/react-effector-fsd/dist run: cp index.html 404.html # === Prepare deploy folder === - name: Prepare deployment folder run: | mkdir -p dist/tanstackquery mkdir -p dist/rtkquery mkdir -p dist/reatom mkdir -p dist/effector cp -r apps/tanstack-query-zustand/dist/* dist/tanstackquery/ cp -r apps/rtk-query/dist/* dist/rtkquery/ cp -r apps/reatom/dist/* dist/reatom/ cp -r apps/react-effector-fsd/dist/* dist/effector/ touch dist/.nojekyll # === Root pages === - name: Copy root index.html run: cp public/index.html dist/index.html - name: Copy root 404.html run: cp public/404.html dist/404.html - name: Add CNAME run: echo 'stg.musicfun.dev' > dist/CNAME - uses: actions/upload-pages-artifact@v3 with: path: dist - id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .cursor .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .cursorignore # env files .env.local .env.development.local .env.test.local .env.production.local ================================================ FILE: .husky/pre-commit ================================================ # ─── Auto-increment VITE_VERSION for rtk-query ────────────────────────────── # # If any staged file inside apps/rtk-query/ changed (excluding .env itself), # bump VITE_VERSION in .env by 1 and stage the updated file. # ───────────────────────────────────────────────────────────────────────────── ENV_FILE="apps/rtk-query/.env" if git diff --cached --name-only -- 'apps/rtk-query/' | grep -qv '\.env$'; then CURRENT=$(grep "^VITE_VERSION=" "$ENV_FILE" | cut -d'=' -f2) NEW=$((CURRENT + 1)) sed -i '' "s/^VITE_VERSION=.*/VITE_VERSION=$NEW/" "$ENV_FILE" git add "$ENV_FILE" echo "✔ Bumped rtk-query VITE_VERSION: $CURRENT → $NEW" fi pnpm exec lint-staged ================================================ FILE: .husky/pre-push ================================================ #!/usr/bin/env sh # This script runs automatically before every "git push". # If any build fails, the push is cancelled — broken code won't reach the remote. # ─── Step 1: Figure out which files were changed ─────────────────────────────── # # @{push} is a git shorthand for "the branch on the remote that we're pushing to". # "git diff --name-only @{push}.." compares the remote state with our local HEAD # and outputs just the file paths (one per line) that differ. # # 2>/dev/null suppresses errors — for example, if the branch has no remote tracking # branch yet (first push), this command would fail. # # If it fails, we fall back to "git diff --name-only HEAD~1" which compares # the latest commit with the one before it (so at least we check the last commit). # ─────────────────────────────────────────────────────────────────────────────── CHANGED=$(git diff --name-only @{push}.. 2>/dev/null || git diff --name-only HEAD~1) # ─── Step 2: Initialize flags for each project ──────────────────────────────── # # We use two boolean flags to track whether each project needs to be built. # They start as "false" and will be set to "true" if relevant files were changed. # ─────────────────────────────────────────────────────────────────────────────── RTK=false TANSTACK=false # ─── Step 3: Check which projects have changed files ────────────────────────── # # We pipe the list of changed files into grep. # -q flag means "quiet" — grep won't print anything, it just sets the exit code: # exit 0 (success) if a match is found, exit 1 (failure) if not. # # "^apps/rtk-query/" means "line starts with apps/rtk-query/" — so any file # inside that folder will match. # # "&&" means "if the previous command succeeded (match found), run the next command". # So if any changed file is inside apps/rtk-query/, we set RTK=true. # Same logic for tanstack-query-zustand. # ─────────────────────────────────────────────────────────────────────────────── echo "$CHANGED" | grep -q "^apps/rtk-query/" && RTK=true echo "$CHANGED" | grep -q "^apps/tanstack-query-zustand/" && TANSTACK=true # ─── Step 4: Type-check only the projects that were changed ─────────────────── # # We run only "tsc -b" (TypeScript compiler in build mode) instead of a full # "pnpm build" (which would also run Vite bundling). tsc catches type errors, # missing imports, wrong props, etc. Vite build is redundant here — it doesn't # check types, just bundles JS. Full build is done in CI instead. # # "|| exit 1" means: if tsc fails (returns non-zero exit code), # immediately exit this script with code 1. A non-zero exit from a pre-push # hook tells git to ABORT the push. This prevents pushing broken code. # ─────────────────────────────────────────────────────────────────────────────── if [ "$RTK" = true ]; then echo "▶ Type-checking rtk-query..." pnpm --prefix apps/rtk-query exec tsc -b || exit 1 fi if [ "$TANSTACK" = true ]; then echo "▶ Type-checking tanstack-query-zustand..." pnpm --prefix apps/tanstack-query-zustand exec tsc -b || exit 1 fi # If we reach this point, either: # - No projects had changes (nothing to check), or # - All type checks passed. # In both cases the script exits with code 0 (success) and git proceeds with the push. ================================================ FILE: .prettierignore ================================================ node_modules dist build .next .nuxt coverage *.log pnpm-lock.yaml ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "bracketSameLine": true, "arrowParens": "always", "endOfLine": "auto", "printWidth": 100, "semi": false } ================================================ FILE: CONTRIBUTING.md ================================================ > [!NOTE] > We prefer English language for all communication. ## Creating an issue Before creating an issue please ensure that the problem is not [already reported](https://github.com/it-incubator/musicfun-react-all-stacks/issues). If you want to report a bug, create a reproduction using StackBlitz or CodeSandbox. If you want to request a feature, add motivation section and some usage examples. ## Sending a Pull Request 1. fork and clone the repository > [!NOTE] > You can just clone the repository if you are a collaborator 2. create a development branch from `main` 3. run the following command in the project root (this will install dependencies for all apps and packages) > [!NOTE] > It is recommended to create a branch from the issue 4. [make changes](#coding-guide) and [commit them](#commit-messages) 5. upload feature branch and create a [Pull Request](https://github.com/it-incubator/musicfun-react-all-stacks/compare) to merge changes to `main` 6. link your PR to the issue using a [closing keyword](https://help.github.com/en/articles/closing-issues-using-keywords) or provide changes description with motivation and explanation in the comment (example: `fix #74`) 7. wait until a team member responds ## Coding guide - use `// @ts-ignore` if you not sure why error appears or you think it could be better, use `// @ts-expect-error` if you sure that error is a mistake ## Commit messages Commit messages should follow the [Conventional Commits](https://conventionalcommits.org) specification: ``` [optional scope]: ``` ### Allowed `` - `chore`: any repository maintainance changes - `feat`: code change that adds a new feature - `fix`: bug fix - `perf`: code change that improves performance - `refactor`: code change that is neither a feature addition nor a bug fix nor a performance improvement - `docs`: documentation only changes - `ci`: a change made to CI configurations and scripts - `style`: cosmetic code change - `test`: change that only adds or corrects tests - `revert`: change that reverts previous commits ### Allowed `` Package directory name. Eg: `/packages/effects` is scoped as `effects`. ### `` rules - should be written in English - should be in imperative mood (like `change` instead `changed` or `changes`) - should not be capitalized - should not have period (`.`) at the end ### Commit message examples ``` docs: fix typo in npm-react fix(core): add check for atoms with equal ids ``` ================================================ FILE: FRONTEND_API_CHANGES.md ================================================ # Frontend API Changes - January 27-29, 2026 This document summarizes the API changes from the last 5 commits that require frontend updates. --- ## Table of Contents 1. [New Endpoints](#new-endpoints) 2. [Response Format Changes](#response-format-changes) 3. [Request Payload Changes (Breaking)](#request-payload-changes-breaking) --- ## New Endpoints ### 1. Get Playlists Count **Endpoint:** `GET /playlists/count/:userId` Returns the total number of playlists for a specific user. **Response:** ```json { "count": 5 } ``` **TypeScript Interface:** ```typescript interface GetPlaylistsCountOutput { count: number } ``` --- ### 2. Get Tracks Count **Endpoint:** `GET /playlists/tracks/count/:userId` Returns the total number of **published** tracks for a specific user. **Response:** ```json { "count": 12 } ``` **TypeScript Interface:** ```typescript interface GetTracksCountOutput { count: number } ``` > **Note:** Only published tracks are counted. Draft/unpublished tracks are excluded. --- ## Response Format Changes ### 1. Playlists List - `description` Field Removed **Endpoint:** `GET /playlists` The `description` field has been **removed** from the playlist list response. **Before:** ```json { "data": [ { "id": "...", "type": "playlists", "attributes": { "title": "My Playlist", "description": "Playlist description", // ❌ REMOVED "tracksCount": 10, ... } } ] } ``` **After:** ```json { "data": [ { "id": "...", "type": "playlists", "attributes": { "title": "My Playlist", "tracksCount": 10, ... } } ] } ``` > **Note:** The `description` field is still available when fetching a **single playlist** via `GET /playlists/:playlistId`. --- ### 2. Playlists - New `tracksCount` Field **Endpoints:** - `GET /playlists` (list) - `GET /playlists/:playlistId` (single) A new `tracksCount` field has been added to playlist responses. **Response:** ```json { "data": { "id": "...", "type": "playlists", "attributes": { "title": "My Playlist", "tracksCount": 10, // ✅ NEW FIELD ... } } } ``` **TypeScript Update:** ```typescript interface PlaylistAttributes { // ... existing fields tracksCount: number // NEW } ``` --- ### 3. Tags - JSON:API Format **Endpoints:** - `POST /tags` (create) - `GET /tags/search` (search) Tags endpoints now return JSON:API formatted responses. **Before (Create):** ```json { "id": "uuid", "name": "Rock" } ``` **After (Create):** ```json { "data": { "id": "uuid", "type": "tags", "attributes": { "name": "Rock" } } } ``` **Before (Search):** ```json [ { "id": "uuid1", "name": "Rock" }, { "id": "uuid2", "name": "Pop" } ] ``` **After (Search):** ```json { "data": [ { "id": "uuid1", "type": "tags", "attributes": { "name": "Rock" } }, { "id": "uuid2", "type": "tags", "attributes": { "name": "Pop" } } ] } ``` **TypeScript Interfaces:** ```typescript interface TagAttributes { name: string } interface TagResource { id: string type: 'tags' attributes: TagAttributes } interface GetTagOutput { data: TagResource } interface GetTagsOutput { data: TagResource[] } ``` --- ## Request Payload Changes (Breaking) All create/update endpoints now use **JSON:API format** for request bodies. ### 1. Create Tag **Endpoint:** `POST /tags` **Before:** ```json { "name": "Rock" } ``` **After:** ```json { "data": { "type": "tags", "attributes": { "name": "Rock" } } } ``` --- ### 2. Create Artist **Endpoint:** `POST /artists` **Before:** ```json { "name": "Artist Name" } ``` **After:** ```json { "data": { "type": "artists", "attributes": { "name": "Artist Name" } } } ``` --- ### 3. Create Playlist **Endpoint:** `POST /playlists` **Before:** ```json { "title": "My Playlist", "description": "Description" } ``` **After:** ```json { "data": { "type": "playlists", "attributes": { "title": "My Playlist", "description": "Description" } } } ``` --- ### 4. Update Playlist **Endpoint:** `PUT /playlists/:id` **Before:** ```json { "title": "Updated Title", "description": "Updated description" } ``` **After:** ```json { "data": { "type": "playlists", "attributes": { "title": "Updated Title", "description": "Updated description" } } } ``` --- ### 5. Upload Track **Endpoint:** `POST /tracks` (multipart/form-data) **Before:** ``` title: "Track Title" artists: ["artist-id-1", "artist-id-2"] tags: ["tag-id-1"] ``` **After:** ``` data[type]: "tracks" data[attributes][title]: "Track Title" data[attributes][artists]: ["artist-id-1", "artist-id-2"] data[attributes][tags]: ["tag-id-1"] ``` --- ### 6. Update Track **Endpoint:** `PATCH /tracks/:id` **Before:** ```json { "title": "Updated Title", "artists": ["artist-id"], "tags": ["tag-id"] } ``` **After:** ```json { "data": { "type": "tracks", "attributes": { "title": "Updated Title", "artists": ["artist-id"], "tags": ["tag-id"] } } } ``` --- ### 7. Add Track to Playlist **Endpoint:** `POST /playlists/:id/tracks` **Before:** ```json { "trackId": "track-uuid" } ``` **After:** ```json { "data": { "type": "playlist-tracks", "attributes": { "trackId": "track-uuid" } } } ``` --- ## Summary of Breaking Changes | Category | Change | Impact | | --------------- | --------------------------------------------------- | --------------------------------------------------- | | Request Format | All create/update payloads now use JSON:API wrapper | **HIGH** - All POST/PUT/PATCH requests need updates | | Response Format | Tags endpoints now return JSON:API format | **MEDIUM** - Update tag parsing logic | | Response Format | Playlist list no longer includes `description` | **LOW** - Remove usage or fetch single playlist | | New Field | `tracksCount` added to playlist responses | **LOW** - Can be used for UI display | | New Endpoints | `/playlists/count/:userId` | **NONE** - New feature | | New Endpoints | `/playlists/tracks/count/:userId` | **NONE** - New feature | --- ## Migration Checklist - [ ] Update all API request payloads to JSON:API format - [ ] Update tag response parsing (access via `response.data` / `response.data.attributes`) - [ ] Remove reliance on `description` field in playlist lists - [ ] Add `tracksCount` to playlist TypeScript interfaces - [ ] (Optional) Implement new count endpoints for user statistics --- ## TypeScript Helper Types ```typescript // Generic JSON:API Request Wrapper interface JsonApiRequest { data: { type: T attributes: A } } // Example usage: type CreateTagRequest = JsonApiRequest<'tags', { name: string }> type CreatePlaylistRequest = JsonApiRequest< 'playlists', { title: string description: string | null } > type CreateArtistRequest = JsonApiRequest<'artists', { name: string }> type UpdateTrackRequest = JsonApiRequest< 'tracks', { title?: string artists?: string[] tags?: string[] } > ``` ================================================ FILE: README.md ================================================ [Figma](https://www.figma.com/design/AxTPd4AS8oAgdEF4dDgLis/MusicFun?node-id=9-353&p=f&t=I0svXbRE8kPWOUFB-0) • [ApiHub](https://apihub.it-incubator.io/en) • [Swagger](https://musicfun.it-incubator.app/api) # 🚀 Project Launch Information on launching projects can be found in the `README.md` of each individual repository. ## Actual projects - `youtube/rtk-query` - youtube lessons: rtk-query - `youtube/tanstack-query-router-fsd` - youtube lessons: tanstack-query - `apps/musicfun-ui-vanilla` - full project html/css/storybook vanilla without ui libraries - `apps/musicfun-tanstack-query` - full project with tanstack query - `apps/musicfun-rtk-query` - full project with rtk-query ## ❌ Project Launch with SDK (Currently Unsupported) ### 1. Installing Dependencies Run the following command in the project root (this will install dependencies for all apps and packages): ```bash pnpm i ``` ### 2. SDK build Then build `musicfun-api-sdk` ```bash pnpm build:sdk ``` ️⚠️ Note: Some scripts may not be cross-platform compatible: ```json "scripts": { "clean": "rm -rf dist", "build": "pnpm run clean && tsc" } ``` If so, try a simpler alternative command: ```bash pnpm build:sdk:simple ``` ### 3. Starting the Project - 🎶musicfun на **tanstack** ```bash pnpm start:musicfun-tanstack ``` - 🎶musicfun на **rtk-query** ```bash pnpm start:musicfun-rtk ``` - 🎶musicfun на **nextjs** ```bash pnpm start:musicfun-nextjs ``` ## ✅ Рекомендованные форматы нейминга файлов в React/TypeScript проектах | Category | Recommended Format | Example | | --------------------- | ------------------ | ------------------------------------- | | **Components** | `PascalCase` | `UserCard.tsx` | | **Hooks** | `camelCase` | `useAuth.ts` | | **Utilities (utils)** | `kebab-case` | `format-date.ts`, `validate-email.ts` | | **Redux Slice/State** | `kebab-case` | `auth-slice.ts`, `user-slice.ts` | | **API files** | `kebab-case` | `playlists-api.ts`, `auth-api.ts` | | **Types/Interfaces** | `kebab-case` | `user.types.ts`, `auth.types.ts` | | **Services** | `kebab-case` | `auth-service.ts`, `user-service.ts` | | **Mocks (mock data)** | `kebab-case` | `user-mocks.ts`, `playlist-mocks.ts` | ## Contributing Please refer to our [Contributing guide](CONTRIBUTING.md) to learn about our development process, how to propose bugfixes ### Happy hacking 🚀 🚀🚀 ================================================ FILE: apps/nextjs/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: apps/nextjs/README.md ================================================ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ================================================ FILE: apps/nextjs/eslint.config.mjs ================================================ import { FlatCompat } from '@eslint/eslintrc' import { dirname } from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const compat = new FlatCompat({ baseDirectory: __dirname, }) const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')] export default eslintConfig ================================================ FILE: apps/nextjs/next.config.ts ================================================ import type { NextConfig } from 'next' const nextConfig: NextConfig = { /* config options here */ } export default nextConfig ================================================ FILE: apps/nextjs/package.json ================================================ { "name": "musicfun-nextjs", "version": "0.1.0", "private": true, "scripts": { "dev": "NODE_OPTIONS='--inspect' next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "next": "15.3.3", "@it-incubator/musicfun-api-sdk": "workspace:*" }, "devDependencies": { "typescript": "^5", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19" } } ================================================ FILE: apps/nextjs/src/app/actions/auth/logout.action.tsx ================================================ 'use server' import { cookies } from 'next/headers' export async function logout() { const cookieStore = await cookies() // удаляем куки, задав maxAge: 0 cookieStore.set('access-token', '', { httpOnly: true, maxAge: 0, path: '/' }) cookieStore.set('refresh-token', '', { httpOnly: true, maxAge: 0, path: '/' }) } ================================================ FILE: apps/nextjs/src/app/api/oauth/callback/route.ts ================================================ import { cookies } from 'next/headers' import { NextResponse } from 'next/server' import { authApi } from '@/shared/api/auth-api' import { redirectAfterOauthUri } from '@/shared/api/base' import { createAccessTokenCookie, createRefreshTokenCookie } from '@/shared/utils/cookieHelpers' export async function GET(request: Request) { const { searchParams } = new URL(request.url) const code = searchParams.get('code') as string const tokens = await authApi.login({ code, redirectUri: redirectAfterOauthUri, accessTokenTTL: '1d', rememberMe: true, }) const cookieStore = await cookies() cookieStore.set(createRefreshTokenCookie(tokens.refreshToken)) cookieStore.set(createAccessTokenCookie(tokens.accessToken)) return NextResponse.redirect(new URL('/', request.url), 307) } ================================================ FILE: apps/nextjs/src/app/globals.css ================================================ :root { --background: #ffffff; --foreground: #171717; } @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; } } html, body { max-width: 100vw; overflow-x: hidden; } body { color: var(--foreground); background: var(--background); font-family: Arial, Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } * { box-sizing: border-box; padding: 0; margin: 0; } a { color: inherit; text-decoration: none; } @media (prefers-color-scheme: dark) { html { color-scheme: dark; } } ================================================ FILE: apps/nextjs/src/app/layout.tsx ================================================ import './globals.css' import type { Metadata } from 'next' import { Geist, Geist_Mono } from 'next/font/google' const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], }) const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], }) export const metadata: Metadata = { title: 'Create Next App', description: 'Generated by create next app', } export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {children} ) } ================================================ FILE: apps/nextjs/src/app/page.module.css ================================================ .page { --gray-rgb: 0, 0, 0; --gray-alpha-200: rgba(var(--gray-rgb), 0.08); --gray-alpha-100: rgba(var(--gray-rgb), 0.05); --button-primary-hover: #383838; --button-secondary-hover: #f2f2f2; display: grid; grid-template-rows: 20px 1fr 20px; align-items: center; justify-items: center; min-height: 100svh; padding: 80px; gap: 64px; font-family: var(--font-geist-sans); } @media (prefers-color-scheme: dark) { .page { --gray-rgb: 255, 255, 255; --gray-alpha-200: rgba(var(--gray-rgb), 0.145); --gray-alpha-100: rgba(var(--gray-rgb), 0.06); --button-primary-hover: #ccc; --button-secondary-hover: #1a1a1a; } } .main { display: flex; flex-direction: column; gap: 32px; grid-row-start: 2; } .main ol { font-family: var(--font-geist-mono); padding-left: 0; margin: 0; font-size: 14px; line-height: 24px; letter-spacing: -0.01em; list-style-position: inside; } .main li:not(:last-of-type) { margin-bottom: 8px; } .main code { font-family: inherit; background: var(--gray-alpha-100); padding: 2px 4px; border-radius: 4px; font-weight: 600; } .ctas { display: flex; gap: 16px; } .ctas a { appearance: none; border-radius: 128px; height: 48px; padding: 0 20px; border: none; border: 1px solid transparent; transition: background 0.2s, color 0.2s, border-color 0.2s; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; line-height: 20px; font-weight: 500; } a.primary { background: var(--foreground); color: var(--background); gap: 8px; } a.secondary { border-color: var(--gray-alpha-200); min-width: 158px; } .footer { grid-row-start: 3; display: flex; gap: 24px; } .footer a { display: flex; align-items: center; gap: 8px; } .footer img { flex-shrink: 0; } /* Enable hover only on non-touch devices */ @media (hover: hover) and (pointer: fine) { a.primary:hover { background: var(--button-primary-hover); border-color: transparent; } a.secondary:hover { background: var(--button-secondary-hover); border-color: transparent; } .footer a:hover { text-decoration: underline; text-underline-offset: 4px; } } @media (max-width: 600px) { .page { padding: 32px; padding-bottom: 80px; } .main { align-items: center; } .main ol { text-align: center; } .ctas { flex-direction: column; } .ctas a { font-size: 14px; height: 40px; padding: 0 16px; } a.secondary { min-width: auto; } .footer { flex-wrap: wrap; align-items: center; justify-content: center; } } @media (prefers-color-scheme: dark) { .logo { filter: invert(); } } ================================================ FILE: apps/nextjs/src/app/page.tsx ================================================ import { UserBlock } from '@/features/auth/ui/UserBlock' import { tracksApi } from '@/shared/api/tracks/tracksApi' import styles from './page.module.css' export default async function Home() { const tracks = await tracksApi.fetchTracks({ pageNumber: 1, pageSize: 5 }) return (

Tracks:

{tracks.data.map((track) => (
  • {track.attributes.title}
  • ))}
    ) } ================================================ FILE: apps/nextjs/src/app/profile/page.tsx ================================================ import { cookies } from 'next/headers' import { authApi } from '@/shared/api/auth-api' import { MeResponseResponse } from '@/shared/api/authApi.types' import { redirectAfterOauthUri } from '@/shared/api/base' export default async function ProfilePage() { let meData: MeResponseResponse | null = null try { meData = await authApi.getMe() } catch (error) {} return meData ? (
    login: {meData.login}, userId: {meData.userId}
    ) : (
    Login
    ) } ================================================ FILE: apps/nextjs/src/app/redirect/page.tsx ================================================ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { authApi } from '@/shared/api/auth-api' import { MeResponseResponse } from '@/shared/api/authApi.types' import { redirectAfterOauthUri } from '@/shared/api/base' export default async function ProfilePage() { let meData: MeResponseResponse | null = null try { meData = await authApi.getMe() } catch (error) {} if (meData) { redirect('/profile') } else { redirect('/') } } ================================================ FILE: apps/nextjs/src/features/auth/ui/Login/Login.tsx ================================================ import { authApi } from '@/shared/api/auth-api' import { redirectAfterOauthUri } from '@/shared/api/base' export const Login = () => { return Login via APIHUB } ================================================ FILE: apps/nextjs/src/features/auth/ui/Logout/Logout.tsx ================================================ 'use client' import { useRouter } from 'next/navigation' import { logout } from '@/app/actions/auth/logout.action' export const Logout = () => { const router = useRouter() const logoutHandler = async () => { await logout() router.push('/') } return } ================================================ FILE: apps/nextjs/src/features/auth/ui/MeInfo/MeInfo.tsx ================================================ import { Logout } from '@/features/auth/ui/Logout/Logout' import { authApi } from '@/shared/api/auth-api' export const MeInfo = async () => { const meData = await authApi.getMe() return (
    userLogin: {meData.login}
    ) } ================================================ FILE: apps/nextjs/src/features/auth/ui/UserBlock.tsx ================================================ import { Login } from '@/features/auth/ui/Login/Login' import { MeInfo } from '@/features/auth/ui/MeInfo/MeInfo' import { authApi } from '@/shared/api/auth-api' import { MeResponseResponse } from '@/shared/api/authApi.types' export const UserBlock = async () => { let meData: MeResponseResponse | null = null try { meData = await authApi.getMe() } catch (error) {} return ( <> {!meData && } {meData && } ) } ================================================ FILE: apps/nextjs/src/middleware.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { reauthMiddleware } from '@/reauth-middleware' export async function middleware(request: NextRequest) { const url = request.nextUrl console.log('Middleware') const response = reauthMiddleware(request) if (response) return response return NextResponse.next() } export const config = { matcher: ['/', '/profile', '/api/:path*'], // или любой другой набор путей } ================================================ FILE: apps/nextjs/src/reauth-middleware.ts ================================================ import { NextRequest, NextResponse } from 'next/server' import { authApi } from '@/shared/api/auth-api' import { createAccessTokenCookie, createRefreshTokenCookie } from '@/shared/utils/cookieHelpers' import { getJwtExpirationMaxAge, getSecondsToExpiration } from '@/shared/utils/jwt-util' const refreshTokens = async (refreshToken: string) => { try { const { accessToken, refreshToken: newRefreshToken } = await authApi.refreshToken({ refreshToken, }) return { accessToken, refreshToken: newRefreshToken } } catch { return null } } export async function reauthMiddleware(request: NextRequest) { const accessCookie = request.cookies.get('access-token') const refreshCookie = request.cookies.get('refresh-token') let tokens: { accessToken: string; refreshToken: string } | null = null if (accessCookie) { const secondsToExpiration = getSecondsToExpiration(accessCookie.value) if (secondsToExpiration < 20 && refreshCookie) { tokens = await refreshTokens(refreshCookie.value) } } else if (refreshCookie) { tokens = await refreshTokens(refreshCookie.value) } const requestHeaders = new Headers(request.headers) if (tokens) { requestHeaders.set('Authorization', 'Bearer ' + tokens.accessToken) } const response = NextResponse.next({ request: { headers: requestHeaders }, }) if (tokens) { response.cookies.set(createAccessTokenCookie(tokens.accessToken)) response.cookies.set(createRefreshTokenCookie(tokens.accessToken)) } return response } ================================================ FILE: apps/nextjs/src/shared/api/auth-api.ts ================================================ import { cookies } from 'next/headers' import { baseUrl, jsonHeaders } from '@/shared/api/base' import type { AuthTokensResponse, MeResponseResponse, OAuthLoginArgs, RefreshTokensArgs, } from './authApi.types' /** * Обёртка над fetch, которая проверяет response.ok */ async function checkResponse(response: Response): Promise { if (!response.ok) { const errorBody = await response.text() throw new Error(`Request failed with status ${response.status}: ${errorBody}`) } return (await response.json()) as T } export const authApi = { // 1) Login → POST /auth/login async login(payload: OAuthLoginArgs): Promise { const response = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload), }) return checkResponse(response) }, // 2) Logout → POST /auth/logout async logout(payload: RefreshTokensArgs): Promise { const response = await fetch(`${baseUrl}/auth/logout`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload), }) // не ожидаем JSON в ответе if (!response.ok) { const errorBody = await response.text() throw new Error(`Logout failed with status ${response.status}: ${errorBody}`) } }, // 3) Получить URL для OAuth-редиректа (без сетевого запроса) oauthUrl(redirectUrl: string): string { // Здесь предполагается, что authEndpoint — это что-то вроде '/api/auth' return `${baseUrl}/auth/oauth-redirect?callbackUrl=${encodeURIComponent(redirectUrl)}` }, // 4) Refresh token → POST /auth/refresh async refreshToken(payload: RefreshTokensArgs): Promise { const response = await fetch(`${baseUrl}/auth/refresh`, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload), }) return checkResponse(response) }, // 5) Get current user → GET /auth/me async getMe(): Promise { const cookieStore = await cookies() const token = cookieStore.get('access-token')?.value const response = await fetch(`${baseUrl}/auth/me`, { method: 'GET', headers: { ...jsonHeaders, Authorization: 'Bearer ' + token, }, // Если нужен авторизационный заголовок — добавьте сюда: // headers: { // Authorization: `Bearer ${localStorage.getItem("accessToken")}`, // }, }) return checkResponse(response) }, } ================================================ FILE: apps/nextjs/src/shared/api/authApi.types.ts ================================================ export type MeResponseResponse = { userId: string login: string } export type AuthTokensResponse = { refreshToken: string accessToken: string } export type RefreshTokensArgs = { refreshToken: string } export type OAuthLoginArgs = { code: string redirectUri: string accessTokenTTL: string // e.g. "3m" rememberMe: boolean } export const localStorageKeys = { refreshToken: 'musicfun-refresh-token', accessToken: 'musicfun-access-token', } ================================================ FILE: apps/nextjs/src/shared/api/base.ts ================================================ export const baseUrl = process.env.BASE_URL! export const apiKey = process.env.API_KEY! export const jsonHeaders = { 'Content-Type': 'application/json', 'api-Key': apiKey, Origin: 'http://localhost:3000', } export const formHeaders = { 'api-Key': apiKey, } export const redirectAfterOauthUri = 'http://localhost:3000/api/oauth/callback' ================================================ FILE: apps/nextjs/src/shared/api/tracks/tracksApi.ts ================================================ import { baseUrl, formHeaders, jsonHeaders } from '@/shared/api/base' import { Nullable } from '@/shared/common.types' import type { FetchPlaylistsTracksResponse, FetchTracksArgs, FetchTracksResponse, ReactionResponse, TrackDetailAttributes, TrackDetails, UpdateTrackArgs, } from './tracksApi.types.ts' export const tracksApi = { async fetchTracks({ pageSize = 3, pageNumber, search = '' }: FetchTracksArgs) { const url = new URL(`${baseUrl}/playlists/tracks`) url.searchParams.set('pageSize', pageSize.toString()) url.searchParams.set('pageNumber', pageNumber.toString()) url.searchParams.set('search', search) const res = await fetch(url.toString(), { headers: jsonHeaders }) return res.json() as Promise }, async fetchTracksInPlaylist({ playlistId }: { playlistId: string }) { const url = `${baseUrl}/playlists/${playlistId}/tracks` const res = await fetch(url, { headers: jsonHeaders }) return res.json() as Promise }, async fetchTrackById(trackId: string) { const url = `${baseUrl}/playlists/tracks/${trackId}` const res = await fetch(url, { headers: jsonHeaders }) return res.json() as Promise<{ data: TrackDetails }> }, async createTrack({ title, file }: { title: string; file: File }) { const formData = new FormData() formData.append('title', title) formData.append('file', file) const url = `${baseUrl}/playlists/tracks/upload` const res = await fetch(url, { method: 'POST', headers: formHeaders, body: formData, }) return res.json() as Promise<{ data: TrackDetails }> }, async removeTrack(trackId: string) { const url = `${baseUrl}/playlists/tracks/${trackId}` await fetch(url, { method: 'DELETE', headers: jsonHeaders }) }, // async uploadTrackCover({ trackId, file }: { trackId: string; file: File }) { // const formData = new FormData() // formData.append('cover', file) // // const url = `${baseUrl}/playlists/tracks/${trackId}/cover` // const res = await fetch(url, { // method: 'POST', // headers: formHeaders, // body: formData, // }) // return res.json() as Promise // }, async updateTrack({ trackId, payload }: { trackId: string; payload: UpdateTrackArgs }) { const url = `${baseUrl}/playlists/tracks/${trackId}` const res = await fetch(url, { method: 'PUT', headers: jsonHeaders, body: JSON.stringify(payload), }) return res.json() as Promise<{ data: TrackDetails }> }, async addTrackToPlaylist({ playlistId, trackId }: { playlistId: string; trackId: string }) { const url = `${baseUrl}/playlists/${playlistId}/relationships/tracks` await fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify({ trackId }), }) }, async removeTrackFromPlaylist({ playlistId, trackId }: { playlistId: string; trackId: string }) { const url = `${baseUrl}/playlists/${playlistId}/relationships/tracks/${trackId}` await fetch(url, { method: 'DELETE', headers: jsonHeaders }) }, async reorderTracks({ trackId, playlistId, putAfterItemId, }: { trackId: string playlistId: string putAfterItemId: Nullable }) { const url = `${baseUrl}/playlists/${playlistId}/tracks/${trackId}/reorder` await fetch(url, { method: 'PUT', headers: jsonHeaders, body: JSON.stringify({ putAfterItemId }), }) }, async like(trackId: string) { const url = `${baseUrl}/playlists/tracks/${trackId}/like` const res = await fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify({}), }) return res.json() as Promise }, async dislike(trackId: string) { const url = `${baseUrl}/playlists/tracks/${trackId}/dislike` const res = await fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify({}), }) return res.json() as Promise }, } ================================================ FILE: apps/nextjs/src/shared/api/tracks/tracksApi.types.ts ================================================ import { Meta, Nullable } from '../../common/types/common.types' import { CurrentUserReaction } from '../../common/types/enums' import { Images, User } from '../../common/types/playlists-tracks.types' import { Artist } from '../artists/artistsApi.types' import { Tag } from '../tags/tagsApi.types' export type TrackDetails = { id: string type: 'tracks' attributes: T } // Attributes export type BaseAttributes = { title: string addedAt: string attachments: TrackAttachment[] images: Images } export type FetchTracksAttributes = BaseAttributes & { user: User } export type TrackDetailAttributes = BaseAttributes & { lyrics: Nullable releaseDate: Nullable updatedAt: string duration: number processingStatus: TrackProcessingStatus visibility: TrackVisibility tags: Tag[] artists: Artist[] // likes currentUserReaction: CurrentUserReaction dislikesCount: number likesCount: number } export type PlaylistItemAttributes = BaseAttributes & { updatedAt: string order: number } // Attachment export type TrackAttachment = { id: string addedAt: string updatedAt: string version: number url: string contentType: string originalName: string originalKey: string fileSize: number } // Response export type FetchTracksResponse = { data: TrackDetails[] meta: Meta } export type FetchPlaylistsTracksResponse = { data: TrackDetails[] meta: Meta } export type ReactionResponse = { objectId: string value: number likes: number dislikes: number } // Arguments export type FetchTracksArgs = { pageSize?: number pageNumber: number search?: string } export type UpdateTrackArgs = { title?: string lyrics?: string visibility?: TrackVisibility releaseDate?: string tagIds?: string[] artistsIds?: string[] } // Literal types type TrackVisibility = 'private' | 'public' type TrackProcessingStatus = 'uploaded' | 'converting' | 'ready' ================================================ FILE: apps/nextjs/src/shared/common.types.ts ================================================ export type Nullable = T | null ================================================ FILE: apps/nextjs/src/shared/utils/cookieHelpers.ts ================================================ // src/shared/utils/cookieHelpers.ts import { getJwtExpirationMaxAge } from '@/shared/utils/jwt-util' export interface CookieDef { name: string value: string httpOnly: boolean maxAge: number path: string } /** * Возвращает определение cookie для access-token */ export function createAccessTokenCookie(token: string): CookieDef { return { name: 'access-token', value: token, httpOnly: true, maxAge: getJwtExpirationMaxAge(token), path: '/', } } /** * Возвращает определение cookie для refresh-token */ export function createRefreshTokenCookie(token: string): CookieDef { return { name: 'refresh-token', value: token, httpOnly: true, maxAge: getJwtExpirationMaxAge(token), path: '/', } } ================================================ FILE: apps/nextjs/src/shared/utils/jwt-util.ts ================================================ // src/shared/utils/jwtUtils.ts /** * Распарсить payload JWT и вернуть поле exp * @throws Error если формат токена некорректен или нет поля exp */ function parseJwtExp(token: string): number { const parts = token.split('.') if (parts.length !== 3) { throw new Error('Invalid JWT format') } // Декодируем payload (в middle-segment) const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf-8') const payload = JSON.parse(payloadJson) if (typeof payload.exp !== 'number') { throw new Error('JWT payload missing exp claim') } return payload.exp } /** * Возвращает, сколько секунд осталось до истечения токена */ export function getSecondsToExpiration(token: string): number { const exp = parseJwtExp(token) const now = Math.floor(Date.now() / 1000) return exp - now } /** * Возвращает maxAge для установки в cookie, основанный на exp токена * @throws Error если токен уже истёк или exp–now ≤ 0 */ export function getJwtExpirationMaxAge(token: string): number { const seconds = getSecondsToExpiration(token) if (seconds <= 0) { throw new Error('Token already expired') } return seconds } ================================================ FILE: apps/nextjs/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: apps/react-effector-fsd/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .DS_Store .env /node_modules/ # React Router /.react-router/ /build/ ================================================ FILE: apps/react-effector-fsd/README.md ================================================ # Welcome to React Router! A modern, production-ready template for building full-stack React applications using React Router. [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) ## Features - 🚀 Server-side rendering - ⚡️ Hot Module Replacement (HMR) - 📦 Asset bundling and optimization - 🔄 Data loading and mutations - 🔒 TypeScript by default - 🎉 TailwindCSS for styling - 📖 [React Router docs](https://reactrouter.com/) ## Getting Started ### Installation Install the dependencies: ```bash npm install ``` ### Development Start the development server with HMR: ```bash npm run dev ``` Your application will be available at `http://localhost:5173`. ## Building for Production Create a production build: ```bash npm run build ``` ## Deployment ### Docker Deployment To build and run using Docker: ```bash docker build -t my-app . # Run the container docker run -p 3000:3000 my-app ``` The containerized application can be deployed to any platform that supports Docker, including: - AWS ECS - Google Cloud Run - Azure Container Apps - Digital Ocean App Platform - Fly.io - Railway ### DIY Deployment If you're familiar with deploying Node applications, the built-in app server is production-ready. Make sure to deploy the output of `npm run build` ``` ├── package.json ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) ├── build/ │ ├── client/ # Static assets │ └── server/ # Server-side code ``` ## Styling This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. --- Built with ❤️ using React Router. ================================================ FILE: apps/react-effector-fsd/eslint.config.js ================================================ import js from '@eslint/js' import { defineConfig, globalIgnores } from 'eslint/config' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import globals from 'globals' import tseslint from 'typescript-eslint' export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, }, ]) ================================================ FILE: apps/react-effector-fsd/index.html ================================================ Musicfun Effector
    ================================================ FILE: apps/react-effector-fsd/package.json ================================================ { "name": "react-effector-fsd", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "start": "vite ./build/server/index.js", "typecheck": "tsc --watch", "generate:api": "pnpm openapi-typescript https://musicfun.it-incubator.app/api-json -o ./src/shared/api/schema.ts --root-types --enum --enum-values --dedupe-enums" }, "dependencies": { "@react-router/node": "^7.9.4", "@react-router/serve": "^7.9.4", "clsx": "^2.1.1", "effector": "^23.4.4", "effector-react": "^23.3.0", "isbot": "^5.1.31", "openapi-fetch": "^0.14.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.9.4", "react-toastify": "^11.0.5" }, "devDependencies": { "@react-router/dev": "^7.9.4", "@storybook/react-vite": "^9.1.13", "@tailwindcss/vite": "^4.1.14", "@types/node": "^24.7.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", "@vitejs/plugin-react": "^5.0.4", "babel-plugin-react-compiler": "^1.0.0", "openapi-typescript": "^7.9.1", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vite": "npm:rolldown-vite@7.1.16", "vite-tsconfig-paths": "^5.1.4" }, "overrides": { "vite": "npm:rolldown-vite@7.1.16" } } ================================================ FILE: apps/react-effector-fsd/src/app/App.tsx ================================================ import '@/app/styles/fonts.css' import '@/app/styles/variables.css' import '@/app/styles/reset.css' import '@/app/styles/global.css' import { useEffect } from 'react' import { ToastContainer } from 'react-toastify' import { appStarted } from './model/init.ts' import { Routing } from './routes' export default function App() { useEffect(() => { appStarted() }, []) return ( <> ) } ================================================ FILE: apps/react-effector-fsd/src/app/main.tsx ================================================ import '@/app/styles/fonts.css' import '@/app/styles/variables.css' import '@/app/styles/reset.css' import '@/app/styles/global.css' import 'react-toastify/dist/ReactToastify.css' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' import App from './App.tsx' createRoot(document.getElementById('root')!).render( ) ================================================ FILE: apps/react-effector-fsd/src/app/model/init.ts ================================================ import { createEvent, sample } from 'effector' import { initApiClientFx } from '@/features/auth/model/model.ts' export const appStarted = createEvent() sample({ clock: appStarted, target: initApiClientFx, }) ================================================ FILE: apps/react-effector-fsd/src/app/routes/Routing.tsx ================================================ import { Route, Routes } from 'react-router' import { OAuthCallback } from '@/pages/auth/OAuthRedirect/OAuthCallback.tsx' import { Home } from '@/pages/home' import { UserPage } from '@/pages/user' import { Layout } from '@/widgets/layout' export const Routing = () => ( } /> }> } /> {/*} />*/} {/*} />*/} {/*} />*/} {/*} />*/} } /> ) ================================================ FILE: apps/react-effector-fsd/src/app/routes/index.ts ================================================ export { Routing } from './Routing' ================================================ FILE: apps/react-effector-fsd/src/app/styles/fonts.css ================================================ /* source: https://gwfh.mranftl.com/fonts/lato?subsets=latin */ /* lato-regular - latin */ @font-face { font-family: Lato; font-weight: 400; font-style: normal; font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ src: url('../../shared/fonts/lato-v24-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* lato-700 - latin */ @font-face { font-family: Lato; font-weight: 700; font-style: normal; font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ src: url('../../shared/fonts/lato-v24-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } /* lato-900 - latin */ @font-face { font-family: Lato; font-weight: 900; font-style: normal; font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ src: url('../../shared/fonts/lato-v24-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ } ================================================ FILE: apps/react-effector-fsd/src/app/styles/global.css ================================================ :root { font-family: Lato, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; line-height: 100%; text-rendering: optimizelegibility; font-synthesis: none; } /* Scrollbar styles */ * { scrollbar-color: var(--color-bg-secondary) var(--color-bg-primary); scrollbar-width: thin; } body { margin: 0; color: var(--color-text-primary); background-color: var(--color-bg-primary); } ================================================ FILE: apps/react-effector-fsd/src/app/styles/reset.css ================================================ /* Modern CSS Reset: https://piccalil.li/blog/a-more-modern-css-reset */ /* Box sizing rules */ *, *::before, *::after { box-sizing: border-box; } /* Prevent font size inflation */ html { text-size-adjust: none; } /* Remove default margin in favour of better control in authored CSS */ body, h1, h2, h3, h4, p, figure, blockquote, dl, dd { margin-block-end: 0; } ul, ol { margin: 0; padding: 0; list-style: none; } /* Set core body defaults */ body { min-height: 100vh; line-height: 1.5; } /* Set shorter line heights on headings and interactive elements */ h1, h2, h3, h4, button, input, label { border: none; line-height: 1.1; } /* Balance text wrapping on headings */ h1, h2, h3, h4 { text-wrap: balance; } /* A elements that don't have a class get default styles */ a { color: currentcolor; text-decoration: none; } /* Make images easier to work with */ img, picture { display: block; max-width: 100%; } /* Inherit fonts for inputs and buttons */ input, button, textarea, select { font-family: inherit; font-size: inherit; } /* Anything that has been anchored to should have extra scroll margin */ :target { scroll-margin-block: 5ex; } ================================================ FILE: apps/react-effector-fsd/src/app/styles/variables.css ================================================ :root { /* * Colors */ --color-accent: #ff38b6; --color-disabled: #858585; --color-outline-focus: #1a75f5; /* Text */ --color-text-primary: #fff; --color-text-primary-reverse: #000; --color-text-secondary: #b3b3b3; --color-text-label: #808080; --color-text-error: #f51a51; /* Backgrounds */ --color-bg-primary: #000; --color-bg-secondary: #141414; --color-bg-primary-reverse: #fff; --color-bg-input-hover: #262626; --color-bg-card: rgb(7 7 7 / 50%); --color-bg-interactive-secondary: #333; /* Borders */ --color-border-base: #7f7f7f; --color-border-input-primary: #4d4d4d; --color-border-input-active: #fffefe; /* * Typography */ /* font-sizes */ --font-size-xxxs: 12px; --font-size-xxs: 13px; --font-size-xs: 14px; --font-size-s: 16px; --font-size-m: 18px; --font-size-l: 20px; --font-size-xl: 24px; --font-size-xxl: 30px; --font-size-xxxl: 60px; /* * Layout */ --header-height: 80px; --player-height: 112px; } ================================================ FILE: apps/react-effector-fsd/src/features/auth/api/login.ts ================================================ import { api } from '@/shared/api/client' import type { LoginRequestPayload, RefreshOutput } from '../model/auth-api.types' export const loginApi = async (payload: LoginRequestPayload): Promise => { const res = await api().POST('/auth/login', { body: payload, }) if (res.error) { throw new Error(res.error?.message) } return res.data } ================================================ FILE: apps/react-effector-fsd/src/features/auth/api/logout.ts ================================================ import { api } from '@/shared/api/client' export const logoutApi = async (refreshToken: string) => { await api().POST('/auth/logout', { body: { refreshToken }, }) } ================================================ FILE: apps/react-effector-fsd/src/features/auth/api/me.ts ================================================ import { api } from '@/shared/api/client' import type { User } from '../model/user.types' export const meApi = async (): Promise => { const res = await api().GET('/auth/me') return res.data ?? null } ================================================ FILE: apps/react-effector-fsd/src/features/auth/index.ts ================================================ export { getOauthRedirectUrl } from './model/auth-api.types' export { $me, loginFx, logoutFx } from './model/model.ts' export * from './ui' ================================================ FILE: apps/react-effector-fsd/src/features/auth/model/auth-api.types.ts ================================================ import { getClientConfig } from '@/shared/api/client.ts' import type { components } from '@/shared/api/schema.ts' export type RefreshOutput = components['schemas']['RefreshOutput'] export type RefreshRequestPayload = components['schemas']['RefreshRequestPayload'] export type LoginRequestPayload = components['schemas']['LoginRequestPayload'] export const localStorageKeys = { refreshToken: 'spotifun-refresh-token', accessToken: 'spotifun-access-token', } export const getOauthRedirectUrl = (redirectUrl: string) => getClientConfig().baseURL + `/auth/oauth-redirect?callbackUrl=${redirectUrl}` ================================================ FILE: apps/react-effector-fsd/src/features/auth/model/model.ts ================================================ import { createEffect, createStore, sample } from 'effector' import { toast } from 'react-toastify' import { setClientConfig } from '@/shared/api/client.ts' import { API_BASE_URL, API_KEY } from '@/shared/config/config.ts' import { loginApi } from '../api/login' import { logoutApi } from '../api/logout' import { meApi } from '../api/me' import { localStorageKeys, type LoginRequestPayload, type RefreshOutput } from './auth-api.types' import type { User } from './user.types' export const initApiClientFx = createEffect(() => { setClientConfig({ baseURL: API_BASE_URL, apiKey: API_KEY, getAccessToken: async () => localStorage.getItem(localStorageKeys.accessToken), getRefreshToken: async () => localStorage.getItem(localStorageKeys.refreshToken), saveAccessToken: async (token) => token ? localStorage.setItem(localStorageKeys.accessToken, token) : localStorage.removeItem(localStorageKeys.accessToken), saveRefreshToken: async (token) => token ? localStorage.setItem(localStorageKeys.refreshToken, token) : localStorage.removeItem(localStorageKeys.refreshToken), toManyRequestsErrorHandler: (message: string | null) => { toast(message) }, logoutHandler: () => {}, }) }) export const fetchMeFx = createEffect(meApi) export const loginFx = createEffect(loginApi) export const logoutFx = createEffect(async () => { const refreshToken = localStorage.getItem(localStorageKeys.refreshToken)! await logoutApi(refreshToken) }) const saveTokensFx = createEffect((data: RefreshOutput) => { localStorage.setItem(localStorageKeys.refreshToken, data.refreshToken) localStorage.setItem(localStorageKeys.accessToken, data.accessToken) }) const clearTokensFx = createEffect(() => { localStorage.removeItem(localStorageKeys.accessToken) localStorage.removeItem(localStorageKeys.refreshToken) }) export const $me = createStore(null) .on(fetchMeFx.doneData, (_, me) => me) .reset(logoutFx.done) export const $isAuthorized = createStore(false) .on(fetchMeFx.doneData, (_, me) => Boolean(me)) .reset(logoutFx.done) sample({ clock: initApiClientFx.done, filter: () => Boolean(localStorage.getItem(localStorageKeys.accessToken)), target: fetchMeFx, }) sample({ clock: loginFx.doneData, target: saveTokensFx, }) sample({ clock: saveTokensFx.done, target: fetchMeFx, }) sample({ clock: logoutFx.done, target: clearTokensFx, }) ================================================ FILE: apps/react-effector-fsd/src/features/auth/model/user.types.ts ================================================ import type { components } from '@/shared/api/schema.ts' export type User = components['schemas']['GetMeOutput'] | null ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/LoginButtonAndModal/LoginButtonAndModal.module.css ================================================ .dialog { width: 376px; padding-bottom: 22px; } .content { display: flex; flex-direction: column; gap: 32px; align-items: center; text-align: center; } .icon { display: flex; align-items: center; justify-content: center; width: 60px; height: 60px; border-radius: 50%; font-size: 24px; background-color: var(--color-accent); } .button { height: 55px; } .secondary { background-color: #555; } ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/LoginButtonAndModal/LoginButtonAndModal.tsx ================================================ import clsx from 'clsx' import { useUnit } from 'effector-react' import { useState } from 'react' import { getOauthRedirectUrl, loginFx } from '@/features/auth' import { Button } from '@/shared/components/Button' import { Dialog, DialogContent, DialogHeader } from '@/shared/components/Dialog' import { Typography } from '@/shared/components/Typography' import { CURRENT_APP_DOMAIN } from '@/shared/config/config' import s from './LoginButtonAndModal.module.css' export const LoginButtonAndModal = () => { const [isOpen, setIsOpen] = useState(false) const [login, loginPending] = useUnit([loginFx, loginFx.pending]) const handleOpenModal = () => setIsOpen(true) const handleCloseModal = () => setIsOpen(false) const loginHandler = () => { const redirectUri = window.location.origin + CURRENT_APP_DOMAIN + 'oauth/callback' // todo: to config const url = getOauthRedirectUrl(redirectUri) window.open(url, 'oauthPopup', 'width=500,height=600') const receiveMessage = async (event: MessageEvent) => { if (event.origin !== window.location.origin) { // todo: to config return } const { code } = event.data if (code) { console.log('✅ code received:', code) window.removeEventListener('message', receiveMessage) // effector login login({ code, accessTokenTTL: '10m', redirectUri, rememberMe: true }) handleCloseModal() } } window.addEventListener('message', receiveMessage) } return ( <> Millions of Songs.
    Free on Musicfun.
    😊
    ) } ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/LoginButtonAndModal/index.ts ================================================ export { LoginButtonAndModal } from './LoginButtonAndModal' ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/ProfileDropdownMenu/ProfileDropdownMenu.module.css ================================================ .trigger { cursor: pointer; display: flex; gap: 8px; align-items: center; } .avatar { overflow: hidden; width: 34px; height: 34px; border-radius: 50%; } .name { color: var(--color-text-primary); } ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/ProfileDropdownMenu/ProfileDropdownMenu.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { ProfileDropdownMenu } from './ProfileDropdownMenu' const meta: Meta = { title: 'entities/ProfileDropdownMenu', component: ProfileDropdownMenu, parameters: { layout: 'centered', }, } export default meta type Story = StoryObj export const Default: Story = { args: { avatar: 'https://unsplash.it/182/182', }, } ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/ProfileDropdownMenu/ProfileDropdownMenu.tsx ================================================ import { useUnit } from 'effector-react' import { Link } from 'react-router' import { $me, logoutFx } from '@/features/auth' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Typography, } from '@/shared/components' import { LogoutIcon, ProfileIcon } from '@/shared/icons' import s from './ProfileDropdownMenu.module.css' export const ProfileDropdownMenu = ({ avatar }: { avatar: string }) => { const [me, logout, logoutPending] = useUnit([$me, logoutFx, logoutFx.pending]) const handleLogout = () => { logout() } return (
    {''}
    {me?.login ?? 'anonymous'}
    My Profile {logoutPending ? 'Logging out...' : 'Logout'}
    ) } ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/ProfileDropdownMenu/index.ts ================================================ export * from './ProfileDropdownMenu' ================================================ FILE: apps/react-effector-fsd/src/features/auth/ui/index.ts ================================================ export * from './LoginButtonAndModal' export * from './ProfileDropdownMenu' ================================================ FILE: apps/react-effector-fsd/src/pages/auth/OAuthRedirect/OAuthCallback.module.css ================================================ .title { text-align: center; font-size: 250px; margin: 0; } .subtitle { text-align: center; font-size: 50px; margin: 0; text-transform: uppercase; } ================================================ FILE: apps/react-effector-fsd/src/pages/auth/OAuthRedirect/OAuthCallback.tsx ================================================ import { useEffect } from 'react' export const OAuthCallback = () => { useEffect(() => { const url = new URL(window.location.href) const code = url.searchParams.get('code') // или code/state, если flow другой if (code && window.opener) { window.opener.postMessage({ code }, '*') // Лучше заменить "*" на точный origin } window.close() }, []) return

    Welcome...

    } ================================================ FILE: apps/react-effector-fsd/src/pages/home/Home.tsx ================================================ export function Home() { return
    Musicfun home page
    } ================================================ FILE: apps/react-effector-fsd/src/pages/home/index.ts ================================================ export { Home } from './Home' ================================================ FILE: apps/react-effector-fsd/src/pages/user/UserPage.tsx ================================================ export const UserPage = () => { return (
    UserPage
    ) } ================================================ FILE: apps/react-effector-fsd/src/pages/user/index.ts ================================================ export { UserPage } from './UserPage' ================================================ FILE: apps/react-effector-fsd/src/shared/api/client.ts ================================================ import createClient, { type Middleware } from 'openapi-fetch' import type { paths } from './schema.ts' const config = { baseURL: null as string | null, apiKey: null as string | null, getAccessToken: null as (() => Promise) | null, saveAccessToken: null as ((accessToken: string | null) => Promise) | null, getRefreshToken: null as (() => Promise) | null, saveRefreshToken: null as ((refreshToken: string | null) => Promise) | null, toManyRequestsErrorHandler: null as ((message: string | null) => void) | null, logoutHandler: null as (() => void) | null, } export const setClientConfig = (newConfig: Partial) => { Object.assign(config, newConfig) _client = undefined // пере-инициализируем } export const getClientConfig = () => ({ ...config }) /* ------------------------------------------------------------------ */ /* 2. Mutex для refresh-а */ /* ------------------------------------------------------------------ */ let refreshPromise: Promise | null = null export function makeRefreshToken(): Promise { if (!refreshPromise) { // 1) создаём «замок» сразу refreshPromise = (async (): Promise => { const refreshToken = await config.getRefreshToken!() if (!refreshToken) throw new Error('No refresh token') const res = await fetch(`${config.baseURL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'API-KEY': config.apiKey!, }, body: JSON.stringify({ refreshToken }), }) if (res.status !== 201) throw new Error('Refresh failed') const { accessToken, refreshToken: newRT } = await res.json() await config.saveAccessToken!(accessToken) await config.saveRefreshToken!(newRT) return accessToken })().finally(() => { refreshPromise = null // 2) снимаем «замок» }) } return refreshPromise } const authMiddleware: Middleware = { /* ---------- REQUEST -------------------------------------------------- */ async onRequest({ request }) { request.headers.set('API-KEY', config.apiKey!) const token = await config.getAccessToken?.() if (token) request.headers.set('Authorization', `Bearer ${token}`) ;(request as any)._retryClone = request.clone() return request }, async onResponse({ request, response }) { const req = request as Request & { _retry: boolean } if (response.status === 429) { const { message } = await response.clone().json() config.toManyRequestsErrorHandler?.(message) } if (response.status !== 401 || request.url.includes('/auth/refresh')) { return response // всё ок } // уже пытались? -> отдаём 401 наружу, чтобы не зациклиться if (req._retry) return response req._retry = true try { const newToken = await makeRefreshToken() // повторяем исходный запрос с новым токеном const orig = (req as any)._retryClone as Request // клон с целым body const retry = new Request(orig, { headers: new Headers(orig.headers) }) retry.headers.set('Authorization', `Bearer ${newToken}`) return await fetch(retry) } catch (error) { console.log(error) // refresh не удался → чистим хранилище, отдаём 401 await config.saveAccessToken!(null) await config.saveRefreshToken!(null) await config.logoutHandler?.() return response } }, } let _client: ReturnType> | undefined const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']) function isLocalClient(): boolean { if (typeof window === 'undefined') return false // не клиент const h = window.location.hostname return LOCAL_HOSTNAMES.has(h) || h.endsWith('.localhost') } export function assertApiConfig() { if (!config.baseURL) { const msg = 'baseURL is required. Call setClientConfig({ baseURL })' console.error(msg) throw new Error(msg) } if (isLocalClient() && !config.apiKey) { const msg = 'apiKey is required when running client on localhost. Call setClientConfig({ apiKey })' console.error(msg) throw new Error(msg) } } export const api = () => { if (_client) return _client assertApiConfig() const client = createClient({ baseUrl: config.baseURL! }) client.use(authMiddleware) _client = client return _client } ================================================ FILE: apps/react-effector-fsd/src/shared/api/schema.ts ================================================ /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ export interface paths { '/playlists/my': { parameters: { query?: never header?: never path?: never cookie?: never } /** * Get my playlists * @deprecated */ get: operations['PlaylistsController_getMyPlaylists'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists': { parameters: { query?: never header?: never path?: never cookie?: never } /** * Retrieve all playlists * @description Query parameters must conform to the **GetPlaylistsRequestPayload** schema. */ get: operations['PlaylistsPublicController_getPlaylists'] put?: never /** Create a new playlist */ post: operations['PlaylistsController_createPlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get a single playlist by ID */ get: operations['PlaylistsPublicController_getPlaylistById'] /** Update a playlist */ put: operations['PlaylistsController_updatePlaylist'] post?: never /** Delete a playlist */ delete: operations['PlaylistsController_deletePlaylist'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/reorder': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never /** Reorder playlists */ put: operations['PlaylistsController_reorderPlaylist'] post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/images/main': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** * Upload playlist cover * @description Minimum height — 500px; image must be square */ post: operations['PlaylistsController_uploadMainImage'] /** Delete playlist cover */ delete: operations['PlaylistsController_deleteTrackCover'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } /** * Get list of all tracks in all playlists. * @description Query-parameters schema → [`GetTracksRequestPayload`](#model-GetTracksRequestPayload) */ get: operations['TracksPublicController_getAllTracks'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get list of tracks in a playlist */ get: operations['TracksPublicController_getPlaylistTracks'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get track details by ID */ get: operations['TracksPublicController_getTrackDetails'] /** Update track information */ put: operations['TracksController_updateTrack'] post?: never /** Permanently delete a track */ delete: operations['TracksController_deleteTrackCompletely'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/likes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Like or toggle like on a track */ post: operations['TracksPublicController_likeTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/dislikes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Dislike or toggle dislike on a track */ post: operations['TracksPublicController_dislikeTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/reactions': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove user reaction from a track */ delete: operations['TracksPublicController_removeTrackReaction'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/likes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Like a playlist */ post: operations['PlaylistsPublicController_likePlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/dislikes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Dislike a playlist */ post: operations['PlaylistsPublicController_dislikePlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/reactions': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove user reaction from a playlist */ delete: operations['PlaylistsPublicController_removePlaylistReaction'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/tracks/{trackId}/reorder': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never /** Reorder tracks in a playlist */ put: operations['TracksController_reorderTrack'] post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/relationships/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Add a track to your playlist */ post: operations['TracksController_addTrackToPlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/relationships/tracks/{trackId}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove a track from your playlist */ delete: operations['TracksController_unbindTrackFromPlaylist'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/actions/publish': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Publish a track (make it publicly available) */ post: operations['TracksController_publishTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/cover': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Upload track cover */ post: operations['TracksController_uploadTrackCover'] /** Delete track cover */ delete: operations['TracksController_deleteTrackCover'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/upload': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a track with MP3 file upload */ post: operations['TracksController_uploadTrackMp3'] delete?: never options?: never head?: never patch?: never trace?: never } '/artists': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a new artist */ post: operations['ArtistsController_createArtist'] delete?: never options?: never head?: never patch?: never trace?: never } '/artists/search': { parameters: { query?: never header?: never path?: never cookie?: never } /** Search artists by substring */ get: operations['ArtistsController_searchArtist'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/artists/{id}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Delete an artist by ID */ delete: operations['ArtistsController_deleteArtist'] options?: never head?: never patch?: never trace?: never } '/auth/oauth-redirect': { parameters: { query?: never header?: never path?: never cookie?: never } /** * OAuth redirect * @description The callback URL to redirect after granting access, https://oauth.apihub.it-incubator.io/realms/apihub/protocol/openid-connect/auth?client_id=musicfun&response_type=code&redirect_uri=http://localhost:3000/oauth2/callback&scope=openid */ get: operations['AuthController_OauthRedirect'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/auth/login': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Log in using the code received after OAuth authorization redirect */ post: operations['AuthController_login'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/refresh': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Refresh refresh/access token pair */ post: operations['AuthController_refresh'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/logout': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Deactivate refresh token */ post: operations['AuthController_logout'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/me': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get current user by access token */ get: operations['AuthController_getMe'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/tags': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a new tag */ post: operations['TagsController_createTag'] delete?: never options?: never head?: never patch?: never trace?: never } '/tags/search': { parameters: { query?: never header?: never path?: never cookie?: never } /** Search tags by substring */ get: operations['TagsController_searchTags'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/tags/{id}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Delete a tag by ID */ delete: operations['TagsController_deleteTag'] options?: never head?: never patch?: never trace?: never } } export type webhooks = Record export interface components { schemas: { UserRef: { /** @description Unique identifier of the user */ id: string /** @description Name of the user */ name: string } /** * @description Type of the image size (e.g., original, thumbnail variants) * @enum {string} */ ImageSizeType: ImageSizeType ImageVariant: { /** @description Type of the image size (e.g., original, thumbnail variants) */ type: components['schemas']['ImageSizeType'] /** @description Image width in pixels */ width: number /** @description Image height in pixels */ height: number /** @description Image file size in bytes */ fileSize: number /** @description Full public URL of the image */ url: string } PlaylistImagesOutputDTO: { /** @description Original images and thumbnail previews */ main?: components['schemas']['ImageVariant'][] } TagRef: { /** @description Unique identifier of the tag */ id: string /** @description Original name of the tag */ name: string } /** * @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike * @enum {number} */ ReactionValue: ReactionValue PlaylistListItemAttributes: { /** @description Title of the playlist */ title: string /** @description Description of the playlist */ description: string | null /** * Format: date-time * @description Date and time when the playlist was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the playlist was last updated (ISO 8601) */ updatedAt: string /** @description Order index of the playlist */ order: number /** @description User who created the playlist */ user: components['schemas']['UserRef'] /** @description Images associated with the playlist */ images: components['schemas']['PlaylistImagesOutputDTO'] /** @description Tags linked to the playlist */ tags: components['schemas']['TagRef'][] /** @description Total number of likes for this playlist */ likesCount: number /** @description Total number of dislikes for this playlist */ dislikesCount: number /** @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike */ currentUserReaction: components['schemas']['ReactionValue'] } PlaylistListItemResource: { /** @description Unique identifier of the playlist */ id: string /** * @description Resource type (should be "playlists") * @example playlists */ type: string attributes: components['schemas']['PlaylistListItemAttributes'] } GetMyPlaylistsOutput: { /** @description Array of playlist resource objects owned by the current user */ data: components['schemas']['PlaylistListItemResource'][] } CreatePlaylistRequestPayload: { /** @description Playlist title (1 to 100 characters) */ title: string /** @description Playlist description (up to 1000 characters) */ description: string | null } PlaylistAttributes: { /** @description Title of the playlist */ title: string /** @description Description of the playlist */ description: string | null /** * Format: date-time * @description Date and time when the playlist was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the playlist was last updated (ISO 8601) */ updatedAt: string /** @description Order index of the playlist */ order: number /** @description User who created the playlist */ user: components['schemas']['UserRef'] /** @description Images associated with the playlist */ images: components['schemas']['PlaylistImagesOutputDTO'] /** @description Tags linked to the playlist */ tags: components['schemas']['TagRef'][] /** @description Total number of likes for this playlist */ likesCount: number /** @description Total number of dislikes for this playlist */ dislikesCount: number /** @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike */ currentUserReaction: components['schemas']['ReactionValue'] } PlaylistResource: { /** @description Unique identifier of the playlist */ id: string /** * @description Resource type (should be "playlists") * @example playlists */ type: string attributes: components['schemas']['PlaylistAttributes'] } GetPlaylistOutput: { data: components['schemas']['PlaylistResource'] } UpdatePlaylistRequestPayload: { /** @description Playlist title (1 – 100 characters) */ title: string /** * @description Playlist description (up to 1000 characters) * @example Cool playlist */ description: string | null /** @description Tag IDs to associate with the playlist (0 – 5 items; [] = clear tags) */ tagIds: string[] } ReorderPlaylistsRequestPayload: { /** * Format: uuid * @description ID of the playlist after which the current playlist should be inserted. Send null to place the playlist at the beginning of the list. */ putAfterItemId: string | null } TrackImages: { /** @description List of original images and thumbnail versions (e.g., original, 320x180, etc.) */ main?: components['schemas']['ImageVariant'][] } GetTracksRequestPayload: { /** * @description Page number for pagination (starting from 1) * @default 1 */ pageNumber: number /** * @description Page size for pagination (between 1 and 20) * @default 20 */ pageSize: number /** @description Search term for filtering playlists by name */ search?: string /** * @description Field by which to sort tracks * @default publishedAt * @enum {string} */ sortBy: PathsPlaylistsTracksGetParametersQuerySortBy /** * @description Sort direction (ascending or descending) * @default desc * @enum {string} */ sortDirection: PathsPlaylistsGetParametersQuerySortDirection /** @description Filter by tag IDs (multiple values allowed) */ tagsIds?: string[] /** @description Filter by artist IDs (multiple values allowed) */ artistsIds?: string[] /** @description Filter by user ID (track creator's ID) */ userId?: string /** @description If true, include unpublished tracks (drafts) of current user if userId === currentUserId */ includeDrafts?: boolean /** * @description Pagination type: "offset" for page-number pagination; "cursor" for keyset/seek-based pagination. * @default offset * @enum {string} */ paginationType: PathsPlaylistsTracksGetParametersQueryPaginationType /** @description Base64-encoded cursor for keyset pagination. Used only if paginationType is "cursor". */ cursor?: string | null } JsonApiErrorSource: { /** * @description e.g. "/data/attributes/field" * @example /data/attributes/field */ pointer?: string /** * @description e.g. "?queryParam" * @example ?queryParam */ parameter?: string } JsonApiError: { /** * @description HTTP status code as a string * @example 404 */ status: string /** * @description Application-specific error code * @example E123 */ code?: Record /** * @description Short, human-readable summary * @example Not Found */ title?: string /** * @description Detailed explanation * @example User with ID 123 not found */ detail?: string /** @description Pointer to the associated entity in the request */ source?: components['schemas']['JsonApiErrorSource'] /** @description Any extra data */ meta?: Record } JsonApiErrorDocument: { /** @description Array of one or more errors */ errors: components['schemas']['JsonApiError'][] /** @description e.g. timestamp, path, traceId, etc. */ meta?: Record } TrackAttachment: { /** @description Unique identifier of the entity */ id: string /** * Format: date-time * @description Date and time when the entity was added */ addedAt: string /** * Format: date-time * @description Date and time when the entity was last updated */ updatedAt: string /** @description Version number of the entity (for concurrency control) */ version: number /** * @description Public URL to access the uploaded file * @example https://cdn.example.com/uploads/track123/cover.jpg */ url: string /** * @description MIME type of the file * @example image/jpeg */ contentType: string /** * @description Original filename uploaded by the user * @example cover.jpg */ originalName: string /** * @description Size of the file in bytes * @example 34872 */ fileSize: number } TrackListItemAttributes: { title: string /** * Format: date-time * @description Date and time when the track was added (ISO 8601) */ addedAt: string likesCount: number attachments: components['schemas']['TrackAttachment'][] images: components['schemas']['TrackImages'] user: components['schemas']['UserRef'] /** * @description 0 – не залогинен или не реагировал; 1 – лайк; −1 – дизлайк * @enum {number} */ currentUserReaction: ReactionValue isPublished: boolean /** * Format: date-time * @description Date and time when the track was published (ISO 8601) */ publishedAt?: string | null } ArtistRelationship: { id: string type: string } ArtistsRelationship: { data: components['schemas']['ArtistRelationship'][] } TrackRelationships: { artists: components['schemas']['ArtistsRelationship'] } TrackListItemResource: { id: string /** @example tracks */ type: string attributes: components['schemas']['TrackListItemAttributes'] relationships: components['schemas']['TrackRelationships'] } JsonApiMetaWithPagingAndCursor: { page: number pageSize: number /** @description Total count may be absent when using keyset pagination */ totalCount: number | null /** @description Total number of pages */ pagesCount: number | null /** @description Cursor for the next page */ nextCursor: string | null } OmitTypeClass: { /** @description Name of the artist */ name: string } IncludedArtistOutput: { id: string type: string attributes: components['schemas']['OmitTypeClass'] } GetTrackListOutput: { data: components['schemas']['TrackListItemResource'][] meta: components['schemas']['JsonApiMetaWithPagingAndCursor'] included: components['schemas']['IncludedArtistOutput'][] } TrackListItemAttributesForPlaylist: { /** @description Title of the track */ title: string /** @description Order index of the track in the playlist */ order: number /** * Format: date-time * @description Date and time when the track was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the track was last updated (ISO 8601) */ updatedAt: string /** @description Attachments related to the track */ attachments: components['schemas']['TrackAttachment'][] /** @description Images associated with the track */ images: components['schemas']['TrackImages'] /** * @description User reaction: 0 – guest or no reaction; 1 – liked; -1 – disliked * @enum {number|null} */ currentUserReaction: ReactionValue /** * Format: date-time * @description Date and time when the track was published (ISO 8601) */ publishedAt?: string | null } TrackListItemResourceForPlaylist: { id: string /** @example tracks */ type: string attributes: components['schemas']['TrackListItemAttributesForPlaylist'] relationships: components['schemas']['TrackRelationships'] } JsonApiMeta: { totalCount: number } GetTracksForPlaylistOutput: { data: components['schemas']['TrackListItemResourceForPlaylist'][] meta: components['schemas']['JsonApiMeta'] included: components['schemas']['IncludedArtistOutput'][] } ArtistRef: { /** @description Unique identifier of the artist */ id: string /** @description Name of the artist */ name: string } TrackDetailsAttributes: { /** @description Track title */ title: string /** @description Track lyrics text */ lyrics?: string | null /** * Format: date-time * @description Release date in ISO 8601 format */ releaseDate?: string | null /** * Format: date-time * @description Date and time when the track was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the track was last updated (ISO 8601) */ updatedAt: string /** @description Duration of the track in seconds */ duration: number /** @description Total number of likes for this track */ likesCount: number /** * @deprecated * @description Total number of dislikes for this track */ dislikesCount: number /** @description List of attachments related to the track */ attachments: components['schemas']['TrackAttachment'][] images: components['schemas']['TrackImages'] /** @description Tags associated with the track */ tags: components['schemas']['TagRef'][] /** @description Artists associated with the track */ artists: components['schemas']['ArtistRef'][] user: components['schemas']['UserRef'] /** @description Publication status of the track */ isPublished: boolean /** * Format: date-time * @description Publication date in ISO 8601 format */ publishedAt?: string | null /** * @description User reaction: 0 – guest or no reaction; 1 – user liked; -1 – user disliked * @enum {number} */ currentUserReaction: ReactionValue } TrackDetailsResource: { /** @description Unique identifier of the track */ id: string /** * @description Resource type (should be "tracks") * @example tracks */ type: string attributes: components['schemas']['TrackDetailsAttributes'] } GetTrackDetailsOutput: { data: components['schemas']['TrackDetailsResource'] } ReactionOutput: { objectId: string /** @enum {number} */ value: ReactionValue likes: number dislikes: number } GetPlaylistsRequestPayload: { /** * @description Page number for pagination (starting from 1) * @default 1 */ pageNumber: number /** * @description Page size for pagination (between 1 and 20) * @default 20 */ pageSize: number /** @description Search term for filtering playlists by name */ search?: string /** * @description Field by which to sort playlists * @default addedAt * @enum {string} */ sortBy: PathsPlaylistsGetParametersQuerySortBy /** * @description Sort direction (ascending or descending) * @default desc * @enum {string} */ sortDirection: PathsPlaylistsGetParametersQuerySortDirection /** @description Filter by tag IDs. Multiple values allowed, e.g.: tagsIds=tag1&tagsIds=tag2 */ tagsIds?: string[] /** @description Filter by user ID (playlist creator’s ID) */ userId?: string /** @description Filter by track ID – only playlists containing this track will be returned */ trackId?: string } JsonApiMetaWithPaging: { totalCount: number page: number pageSize: number pagesCount: number } GetPlaylistsOutput: { /** @description Array of playlist resource objects */ data: components['schemas']['PlaylistListItemResource'][] meta: components['schemas']['JsonApiMetaWithPaging'] } ReorderTracksRequestPayload: { /** * Format: uuid * @description ID of the track after which the current track should be inserted. Send null to place the track at the beginning of the list. * @example a1b2c3d4-e5f6-7890-abcd-1234567890ef */ putAfterItemId: string | null } UpdateTrackRequestPayload: { /** @description Track title (1 to 100 characters) */ title: string /** @description Track lyrics (up to 5000 characters) */ lyrics: string | null /** * Format: date-time * @description Release date in ISO 8601 format */ releaseDate: string | null /** @description Array of tag IDs to associate with the track (up to 5) */ tagIds: string[] /** @description Array of artist IDs to associate with the track (up to 5) */ artistsIds: string[] } AddTrackToPlaylistRequestPayload: { /** @description ID of the track to add to the playlist */ trackId: string } CreateArtistRequestPayload: { /** @description Artist name (must be between 2 and 30 characters) */ name: string } LoginRequestPayload: { /** @description Authorization code received from OAuth server after redirect */ code: string /** * @description Specify the same redirect URI used in the initial OAuth server request * @example http://localhost:3000/oauth2/callback */ redirectUri: string /** * @description Access token lifetime (default "3m"); must be a string like "60s", "3m", "2h", or "1d" * @example 3m */ accessTokenTTL?: string /** @description Refresh token lifetime: if true, 30 days; if false, 30 minutes. accessTokenTTL must not exceed the refresh token lifetime */ rememberMe: boolean } RefreshOutput: { refreshToken: string accessToken: string } BadRequestException: Record UnauthorizedException: Record RefreshRequestPayload: { refreshToken: string } LogoutRequestPayload: { refreshToken: string } GetMeOutput: { userId: string login: string } CreateTagRequestPayload: { /** @description Tag name (2 to 30 characters) */ name: string } /** * Format: binary * @description Файл в multipart/form-data */ BinaryFile: string } responses: never parameters: never requestBodies: never headers: never pathItems: never } export type SchemaUserRef = components['schemas']['UserRef'] export type SchemaImageVariant = components['schemas']['ImageVariant'] export type SchemaPlaylistImagesOutputDto = components['schemas']['PlaylistImagesOutputDTO'] export type SchemaTagRef = components['schemas']['TagRef'] export type SchemaPlaylistListItemAttributes = components['schemas']['PlaylistListItemAttributes'] export type SchemaPlaylistListItemResource = components['schemas']['PlaylistListItemResource'] export type SchemaGetMyPlaylistsOutput = components['schemas']['GetMyPlaylistsOutput'] export type SchemaCreatePlaylistRequestPayload = components['schemas']['CreatePlaylistRequestPayload'] export type SchemaPlaylistAttributes = components['schemas']['PlaylistAttributes'] export type SchemaPlaylistResource = components['schemas']['PlaylistResource'] export type SchemaGetPlaylistOutput = components['schemas']['GetPlaylistOutput'] export type SchemaUpdatePlaylistRequestPayload = components['schemas']['UpdatePlaylistRequestPayload'] export type SchemaReorderPlaylistsRequestPayload = components['schemas']['ReorderPlaylistsRequestPayload'] export type SchemaTrackImages = components['schemas']['TrackImages'] export type SchemaGetTracksRequestPayload = components['schemas']['GetTracksRequestPayload'] export type SchemaJsonApiErrorSource = components['schemas']['JsonApiErrorSource'] export type SchemaJsonApiError = components['schemas']['JsonApiError'] export type SchemaJsonApiErrorDocument = components['schemas']['JsonApiErrorDocument'] export type SchemaTrackAttachment = components['schemas']['TrackAttachment'] export type SchemaTrackListItemAttributes = components['schemas']['TrackListItemAttributes'] export type SchemaArtistRelationship = components['schemas']['ArtistRelationship'] export type SchemaArtistsRelationship = components['schemas']['ArtistsRelationship'] export type SchemaTrackRelationships = components['schemas']['TrackRelationships'] export type SchemaTrackListItemResource = components['schemas']['TrackListItemResource'] export type SchemaJsonApiMetaWithPagingAndCursor = components['schemas']['JsonApiMetaWithPagingAndCursor'] export type SchemaOmitTypeClass = components['schemas']['OmitTypeClass'] export type SchemaIncludedArtistOutput = components['schemas']['IncludedArtistOutput'] export type SchemaGetTrackListOutput = components['schemas']['GetTrackListOutput'] export type SchemaTrackListItemAttributesForPlaylist = components['schemas']['TrackListItemAttributesForPlaylist'] export type SchemaTrackListItemResourceForPlaylist = components['schemas']['TrackListItemResourceForPlaylist'] export type SchemaJsonApiMeta = components['schemas']['JsonApiMeta'] export type SchemaGetTracksForPlaylistOutput = components['schemas']['GetTracksForPlaylistOutput'] export type SchemaArtistRef = components['schemas']['ArtistRef'] export type SchemaTrackDetailsAttributes = components['schemas']['TrackDetailsAttributes'] export type SchemaTrackDetailsResource = components['schemas']['TrackDetailsResource'] export type SchemaGetTrackDetailsOutput = components['schemas']['GetTrackDetailsOutput'] export type SchemaReactionOutput = components['schemas']['ReactionOutput'] export type SchemaGetPlaylistsRequestPayload = components['schemas']['GetPlaylistsRequestPayload'] export type SchemaJsonApiMetaWithPaging = components['schemas']['JsonApiMetaWithPaging'] export type SchemaGetPlaylistsOutput = components['schemas']['GetPlaylistsOutput'] export type SchemaReorderTracksRequestPayload = components['schemas']['ReorderTracksRequestPayload'] export type SchemaUpdateTrackRequestPayload = components['schemas']['UpdateTrackRequestPayload'] export type SchemaAddTrackToPlaylistRequestPayload = components['schemas']['AddTrackToPlaylistRequestPayload'] export type SchemaCreateArtistRequestPayload = components['schemas']['CreateArtistRequestPayload'] export type SchemaLoginRequestPayload = components['schemas']['LoginRequestPayload'] export type SchemaRefreshOutput = components['schemas']['RefreshOutput'] export type SchemaBadRequestException = components['schemas']['BadRequestException'] export type SchemaUnauthorizedException = components['schemas']['UnauthorizedException'] export type SchemaRefreshRequestPayload = components['schemas']['RefreshRequestPayload'] export type SchemaLogoutRequestPayload = components['schemas']['LogoutRequestPayload'] export type SchemaGetMeOutput = components['schemas']['GetMeOutput'] export type SchemaCreateTagRequestPayload = components['schemas']['CreateTagRequestPayload'] export type SchemaBinaryFile = components['schemas']['BinaryFile'] export type $defs = Record export interface operations { PlaylistsController_getMyPlaylists: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of playlists retrieved successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetMyPlaylistsOutput'] } } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_getPlaylists: { parameters: { query?: { /** @description Page number for pagination (starting from 1) */ pageNumber?: number /** @description Page size for pagination (between 1 and 20) */ pageSize?: number /** @description Search term for filtering playlists by name */ search?: string /** @description Field by which to sort playlists */ sortBy?: PathsPlaylistsGetParametersQuerySortBy /** @description Sort direction (ascending or descending) */ sortDirection?: PathsPlaylistsGetParametersQuerySortDirection /** @description Filter by tag IDs. Multiple values allowed, e.g.: tagsIds=tag1&tagsIds=tag2 */ tagsIds?: string[] /** @description Filter by user ID (playlist creator’s ID) */ userId?: string /** @description Filter by track ID – only playlists containing this track will be returned */ trackId?: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: JSON:API list of playlists with pagination */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistsOutput'] } } } } PlaylistsController_createPlaylist: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreatePlaylistRequestPayload'] } } responses: { /** @description Created: Playlist created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistOutput'] } } /** @description Forbidden: Playlist creation limit exceeded */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_getPlaylistById: { parameters: { query?: never header?: never path: { /** @description ID of the playlist */ playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Playlist retrieved successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistOutput'] } } /** @description Not Found: Playlist with the given ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_updatePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['UpdatePlaylistRequestPayload'] } } responses: { /** @description No Content: Playlist updated successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Bad Request: Validation error (e.g., tag limit exceeded) */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: You do not have permission to update this playlist */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_deletePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Playlist deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Insufficient permissions to delete this playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_reorderPlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['ReorderPlaylistsRequestPayload'] } } responses: { /** @description No Content: Playlist order updated successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist or putAfterItemId not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_uploadMainImage: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'multipart/form-data': { /** @description Maximum size 1 MB; minimum height 500px; image must be square */ file: components['schemas']['BinaryFile'] } } } responses: { /** @description OK: Cover uploaded successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['TrackImages'] } } /** @description Bad Request: Invalid image format or dimensions */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No permission to upload cover for this playlist */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_deleteTrackCover: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Cover deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Removing another user’s playlist cover is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_getAllTracks: { parameters: { query?: { /** @description Page number for pagination (starting from 1) */ pageNumber?: number /** @description Page size for pagination (between 1 and 20) */ pageSize?: number /** @description Search term for filtering playlists by name */ search?: string /** @description Field by which to sort tracks */ sortBy?: PathsPlaylistsTracksGetParametersQuerySortBy /** @description Sort direction (ascending or descending) */ sortDirection?: PathsPlaylistsGetParametersQuerySortDirection /** @description Filter by tag IDs (multiple values allowed) */ tagsIds?: string[] /** @description Filter by artist IDs (multiple values allowed) */ artistsIds?: string[] /** @description Filter by user ID (track creator's ID) */ userId?: string /** @description If true, include unpublished tracks (drafts) of current user if userId === currentUserId */ includeDrafts?: boolean /** @description Pagination type: "offset" for page-number pagination; "cursor" for keyset/seek-based pagination. */ paginationType?: PathsPlaylistsTracksGetParametersQueryPaginationType /** @description Base64-encoded cursor for keyset pagination. Used only if paginationType is "cursor". */ cursor?: string | null } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Paginated list of tracks */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackListOutput'] } } /** @description Bad Request: invalid query parameters */ 400: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['JsonApiErrorDocument'] } } } } TracksPublicController_getPlaylistTracks: { parameters: { query?: never header?: never path: { /** @description ID of the playlist to retrieve tracks for */ playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: List of tracks in the playlist */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTracksForPlaylistOutput'] } } /** @description Not Found: Playlist with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_getTrackDetails: { parameters: { query?: never header?: never path: { /** @description ID of the track to retrieve details for */ trackId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Track details with attachments */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackDetailsOutput'] } } /** @description Not Found: Track with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_updateTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['UpdateTrackRequestPayload'] } } responses: { /** @description OK: Track updated successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackDetailsOutput'] } } /** @description Bad Request: Tag or artist limit exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Editing another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track or playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_deleteTrackCompletely: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track permanently deleted */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Deleting another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_likeTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description Created: User reaction recorded and counters updated */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid track ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_dislikeTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description Created: User reaction recorded and counters updated */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid track ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_removeTrackReaction: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Reaction removed successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_likePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description Created: Like recorded successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid playlist ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_dislikePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description Created: Dislike recorded successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid playlist ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_removePlaylistReaction: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Reaction removed successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_reorderTrack: { parameters: { query?: never header?: never path: { playlistId: string trackId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['ReorderTracksRequestPayload'] } } responses: { /** @description OK: Track order updated successfully */ 200: { headers: { [name: string]: unknown } content?: never } /** @description Bad Request: Cannot place a track after itself */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track or putAfterItemId not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_addTrackToPlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['AddTrackToPlaylistRequestPayload'] } } responses: { /** @description No Content: Track added to the playlist successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist or track limit exceeded (max 10 tracks) */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_unbindTrackFromPlaylist: { parameters: { query?: never header?: never path: { playlistId: string trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track removed from the playlist */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_publishTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track published successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Publishing another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Track is already published */ 409: { headers: { [name: string]: unknown } content?: never } } } TracksController_uploadTrackCover: { parameters: { query?: never header?: never path: { /** @description ID of the track for which the cover is being uploaded */ trackId: string } cookie?: never } /** @description Image file:
    * • Field name — cover
    * • Allowed MIME types — image/jpeg, image/png, image/gif
    * • Maximum size — 100 KB */ requestBody: { content: { 'multipart/form-data': { /** Format: binary */ cover: string } } } responses: { /** @description OK: Cover uploaded successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['TrackImages'] } } /** @description Bad Request: Invalid file or size exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Cannot upload a cover for another user’s track */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_deleteTrackCover: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Cover deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Removing another user's track cover is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_uploadTrackMp3: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'multipart/form-data': { /** @example My cool track */ title: string /** Format: binary */ file: string } } } responses: { /** @description OK: Track created successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackDetailsOutput'] } } /** @description Bad Request: Invalid file format or file size exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Internal Server Error: Error saving file or track */ 500: { headers: { [name: string]: unknown } content?: never } } } ArtistsController_createArtist: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreateArtistRequestPayload'] } } responses: { /** @description Created: Artist created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ArtistRef'] } } /** @description Bad Request: Validation error or invalid input */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Limit of 100 artists per user reached */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Artist with the given name already exists */ 409: { headers: { [name: string]: unknown } content?: never } } } ArtistsController_searchArtist: { parameters: { query: { search: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of artists matching the search */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ArtistRef'][] } } } } ArtistsController_deleteArtist: { parameters: { query?: never header?: never path: { id: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Artist deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Artist is attached to tracks or was created by another user */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Artist with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } AuthController_OauthRedirect: { parameters: { query: { /** @description The callback URL to redirect after grand access, * https://oauth.apihub.it-incubator.io/realms/apihub/protocol/openid-connect/auth?client_id=musicfun&response_type=code&redirect_uri=http://localhost:3000/oauth2/callback&scope=openid */ callbackUrl: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Redirect executed successfully */ 200: { headers: { [name: string]: unknown } content?: never } } } AuthController_login: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['LoginRequestPayload'] } } responses: { /** @description OK: Token pair retrieved successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['RefreshOutput'] } } /** @description Bad Request: Invalid request format or required parameters are missing */ 400: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['BadRequestException'] } } /** @description Unauthorized: Code is invalid, expired, missing, or redirectUri does not match */ 401: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['UnauthorizedException'] } } } } AuthController_refresh: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['RefreshRequestPayload'] } } responses: { /** @description OK: Token pair refreshed successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['RefreshOutput'] } } /** @description Unauthorized: Refresh token is invalid, expired, or missing */ 401: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['UnauthorizedException'] } } } } AuthController_logout: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['LogoutRequestPayload'] } } responses: { /** @description No Content: Refresh token deactivated; access token remains valid. */ 204: { headers: { [name: string]: unknown } content?: never } } } AuthController_getMe: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Successfully retrieved user information */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetMeOutput'] } } /** @description Unauthorized: access token is missing or invalid */ 401: { headers: { [name: string]: unknown } content?: never } } } TagsController_createTag: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreateTagRequestPayload'] } } responses: { /** @description Created: Tag created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['TagRef'] } } /** @description Bad Request: Validation error */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Limit of 100 tags per user reached */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Tag with the given name already exists */ 409: { headers: { [name: string]: unknown } content?: never } } } TagsController_searchTags: { parameters: { query: { /** @description Substring to search tags by (using normalized name) */ search: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of matching tags */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['TagRef'][] } } /** @description Bad Request: Invalid search query */ 400: { headers: { [name: string]: unknown } content?: never } } } TagsController_deleteTag: { parameters: { query?: never header?: never path: { /** @description ID of the tag to delete */ id: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Tag deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Tag was created by another user or is attached to tracks or playlists */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Tag with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } } export enum PathsPlaylistsGetParametersQuerySortBy { addedAt = 'addedAt', likesCount = 'likesCount', } export enum PathsPlaylistsGetParametersQuerySortDirection { asc = 'asc', desc = 'desc', } export enum PathsPlaylistsTracksGetParametersQuerySortBy { publishedAt = 'publishedAt', likesCount = 'likesCount', } export enum PathsPlaylistsTracksGetParametersQueryPaginationType { offset = 'offset', cursor = 'cursor', } export enum ImageSizeType { original = 'original', thumbnail = 'thumbnail', medium = 'medium', } export enum ReactionValue { Value0 = 0, Value1 = 1, ValueMinus1 = -1, } ================================================ FILE: apps/react-effector-fsd/src/shared/api/utils/json-api-error.ts ================================================ export interface JsonApiError { status: string code?: string | number title?: string detail?: string source?: { pointer?: string; parameter?: string } meta?: Record } export interface JsonApiErrorDocument { errors: JsonApiError[] meta?: Record } export type ExtractError = T extends { error?: infer E } ? E : unknown /* --- типы ошибок, совпадающие с фильтром -------------------------------- */ export interface JsonApiError { status: string code?: string | number title?: string detail?: string source?: { pointer?: string; parameter?: string } meta?: Record } export interface JsonApiErrorDocument { errors: JsonApiError[] meta?: Record } export function isJsonApiErrorDocument(error: unknown): error is JsonApiErrorDocument { return ( typeof error === 'object' && error !== null && // @ts-expect-error type no matter Array.isArray(error.errors) ) } export function parseJsonApiErrors(errorDoc: JsonApiErrorDocument): { fieldErrors: Record globalErrors: string[] } { const fieldErrors: Record = {} const globalErrors: string[] = [] for (const err of errorDoc.errors) { const msg = err.detail ?? err.title ?? 'Unknown error' const ptr = err.source?.pointer if (ptr) { // убираем префикс JSON:API const field = ptr.replace(/^\/data\/attributes\//, '') fieldErrors[field] = msg } else { globalErrors.push(msg) } } return { fieldErrors, globalErrors } } ================================================ FILE: apps/react-effector-fsd/src/shared/api/utils/request-wrapper.ts ================================================ // types/api.ts import { type ExtractError } from './json-api-error.ts' //----------------------------------------------------------------------------- // utils/requestWrapper.ts //----------------------------------------------------------------------------- // «Умный» обёртчик: Infers Data и Error из P, // возвращает Promise, а в случае ошибки — throw Error export type ExtractData = T extends { data?: infer D } ? NonNullable : never export async function requestWrapper

    >( promise: P ): Promise>> { const res = (await promise) as Awaited

    if ((res as { error?: unknown }).error) { // здесь E = ExtractError> throw (res as { error: ExtractError> }).error } return (res as { data: ExtractData> }).data } ================================================ FILE: apps/react-effector-fsd/src/shared/components/AudioPlayer/AudioPlayer.module.css ================================================ .player { display: flex; gap: 16px; align-items: center; justify-content: space-between; width: 100%; min-height: 64px; background: var(--color-bg-primary); } .trackInfo { display: flex; gap: 12px; align-items: center; min-width: 200px; } .cover { width: 112px; height: 112px; border-radius: 4px; background: var(--color-bg-card); } .cover img { width: 100%; height: 100%; object-fit: cover; } .info { display: flex; flex-direction: column; gap: 2px; } .playerControls { display: flex; flex: 1; flex-direction: column; gap: 8px; align-items: center; } .controls { display: flex; gap: 16px; align-items: center; } .playPauseButton { width: 48px; height: 48px; } .active { color: var(--color-accent); } .iconButton.active:hover, .iconButton.active:focus { color: var(--color-accent); } .progressBar { display: flex; gap: 8px; align-items: center; width: 100%; max-width: 632px; } .time { min-width: 36px; font-size: var(--font-size-xs); color: var(--color-text-secondary); text-align: center; } .progress { cursor: pointer; height: 5px; border: none; border-radius: 4px; accent-color: var(--color-text-primary); } .trackProgress { width: 100%; max-width: 550px; } .volumeColumn { display: flex; align-items: center; justify-content: flex-end; min-width: 160px; padding-right: 32px; } .volumeProgress { width: 119px; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/AudioPlayer/AudioPlayer.stories.tsx ================================================ import type { Meta } from '@storybook/react-vite' import { useState } from 'react' import { AudioPlayer } from './AudioPlayer.tsx' const meta = { title: 'Components/Player', component: AudioPlayer, parameters: {}, args: {}, } satisfies Meta export default meta const demoTrack = { src: 'https://cdn.uppbeat.io/audio-files/c636d7c86452449b1203fc0bded83e29/4358717fc9da477a52fb18a6cbd3afcc/d154b5ce5ff1a05ae8115a3c678062e8/STREAMING-dreamland-matrika-main-version-31140-02-25.mp3', cover: 'https://unsplash.it/112/112', title: 'Play It Safe', artist: 'Julia Wolf', } export const Basic = { render: () => { const [isPlaying, setIsPlaying] = useState(false) const [isShuffle, setIsShuffle] = useState(false) const [isRepeat, setIsRepeat] = useState(false) const [track] = useState(demoTrack) return ( {}} onPrevious={() => {}} isShuffle={isShuffle} isRepeat={isRepeat} onShuffle={() => setIsShuffle(!isShuffle)} onRepeat={() => setIsRepeat(!isRepeat)} /> ) }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/AudioPlayer/AudioPlayer.tsx ================================================ import { clsx } from 'clsx' import { type ComponentProps, useRef, useState } from 'react' import { PauseIcon, PlayIcon, RepeatIcon, ShuffleIcon, SkipNextIcon, SkipPreviousIcon, VolumeIcon, VolumeMuteIcon, } from '@/shared/icons' import { IconButton } from '../IconButton' import { Typography } from '../Typography' import s from './AudioPlayer.module.css' export type PlayerProps = { src: string cover: string title: string artist: string isPlaying: boolean setIsPlaying: (isPlaying: boolean) => void onNext: () => void onPrevious: () => void isShuffle: boolean isRepeat: boolean onShuffle: () => void onRepeat: () => void } & ComponentProps<'div'> export const AudioPlayer = ({ src, cover, title, artist, isPlaying, setIsPlaying, onNext, onPrevious, isShuffle, isRepeat, onShuffle, onRepeat, className, ...props }: PlayerProps) => { const audioRef = useRef(null) const [currentTime, setCurrentTime] = useState(0) const [volume, setVolume] = useState(1) const [duration, setDuration] = useState(0) const handlePlayPause = () => { const audio = audioRef.current if (!audio) return if (isPlaying) { audio.pause() } else { audio.play().catch((e) => { console.error('Audio play error:', e) }) } setIsPlaying(!isPlaying) } const handleChangeTime = (e: React.ChangeEvent) => { const time = Number(e.target.value) setCurrentTime(time) if (audioRef.current) { audioRef.current.currentTime = time } } const handleVolume = (e: React.ChangeEvent) => { const newVolume = Number(e.target.value) setVolume(newVolume) if (audioRef.current) { audioRef.current.volume = newVolume } } const handleVolumeMute = () => { const newVolume = volume > 0 ? 0 : 1 setVolume(newVolume) if (audioRef.current) { audioRef.current.volume = newVolume } } return (

    ) } const format = (sec: number) => { const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${s.toString().padStart(2, '0')}` } ================================================ FILE: apps/react-effector-fsd/src/shared/components/AudioPlayer/index.ts ================================================ export * from './AudioPlayer.tsx' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Autocomplete/Autocomplete.module.css ================================================ .container { position: relative; display: flex; flex-direction: column; width: 100%; } .label { font-size: var(--font-size-s); line-height: 1.7; color: var(--color-text-label); } .labelError { color: var(--color-text-error); } .inputWrapper { position: relative; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-height: 48px; padding: 4px 8px; border: 1px solid var(--color-border-input-primary); border-radius: 4px; background-color: var(--color-bg-primary); transition: all 200ms ease; } .inputWrapper:hover:not(.disabled) { background-color: var(--color-bg-input-hover); } .inputWrapper.focused { border-color: var(--color-border-input-active); background-color: var(--color-bg-primary); } .inputWrapper.error { border-color: var(--color-text-error); } .inputWrapper.disabled { cursor: not-allowed; background-color: var(--color-disabled); } .tag { display: flex; gap: 4px; align-items: center; padding: 2px 6px; border: 1px solid var(--color-border-base); border-radius: 16px; background-color: var(--color-bg-secondary); transition: all 200ms ease; } .tag:hover { background-color: var(--color-bg-input-hover); } .tagText { font-size: var(--font-size-s); font-weight: 500; color: var(--color-text-primary); white-space: nowrap; } .deleteButton { width: 16px; height: 16px; padding: 0; font-size: 10px; color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .deleteButton:hover { color: var(--color-text-error); background-color: transparent; } .inputContainer { position: relative; display: flex; flex: 1; align-items: center; min-width: 120px; } .searchIcon { pointer-events: none; position: absolute; z-index: 1; left: 4px; width: 16px; height: 16px; color: var(--color-text-secondary); transition: color 200ms ease; } .input { width: 100%; padding: 4px 8px 4px 24px; border: none; font-size: var(--font-size-m); color: var(--color-text-primary); background: transparent; outline: none; transition: all 200ms ease; } .input::placeholder { color: var(--color-text-secondary); } .input:disabled { cursor: not-allowed; color: var(--color-disabled); } .dropdownIcon { cursor: pointer; width: 20px; height: 20px; margin-left: 4px; color: var(--color-text-secondary); transition: transform 200ms ease; } .dropdownIcon:hover { color: var(--color-text-primary); } .dropdownIconOpen { transform: rotate(180deg); } .dropdown { position: absolute; z-index: 50; top: 100%; left: 0; overflow-y: auto; width: 100%; max-height: 200px; margin-top: 4px; padding: 4px; border: 1px solid var(--color-border-base); border-radius: 4px; background-color: var(--color-bg-primary); box-shadow: 0 10px 38px -10px rgb(22 23 24 / 35%), 0 10px 20px -15px rgb(22 23 24 / 20%); animation: dropdown-show 200ms ease-out; } .option { cursor: pointer; display: flex; align-items: center; padding: 8px 12px; border-radius: 4px; transition: all 200ms ease; } .option:hover:not(.optionDisabled) { background-color: var(--color-bg-input-hover); } .optionFocused:not(.optionDisabled) { color: var(--color-bg-primary); background-color: var(--color-accent); } .optionDisabled { cursor: not-allowed; opacity: 0.5; } .noResults { padding: 12px; text-align: center; } .noResultsText { color: var(--color-text-secondary); } .errorMessage { margin-top: 4px; font-size: var(--font-size-s); color: var(--color-text-error); } .counter { margin-top: 4px; color: var(--color-text-secondary); } /* Animations */ @keyframes dropdown-show { from { transform: translateY(-4px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Autocomplete/Autocomplete.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Button } from '../Button' import { Card } from '../Card' import { Dialog, DialogContent, DialogFooter, DialogHeader } from '../Dialog' import { Typography } from '../Typography' import { Autocomplete, type AutocompleteOption } from './Autocomplete' const meta = { title: 'Components/Autocomplete', component: Autocomplete, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj // Sample data const programmingLanguages: AutocompleteOption[] = [ { value: 'javascript', label: 'JavaScript' }, { value: 'typescript', label: 'TypeScript' }, { value: 'python', label: 'Python' }, { value: 'java', label: 'Java' }, { value: 'cpp', label: 'C++' }, { value: 'csharp', label: 'C#' }, { value: 'php', label: 'PHP' }, { value: 'ruby', label: 'Ruby' }, { value: 'go', label: 'Go' }, { value: 'rust', label: 'Rust' }, { value: 'kotlin', label: 'Kotlin' }, { value: 'swift', label: 'Swift' }, ] const musicGenres: AutocompleteOption[] = [ { value: 'rock', label: 'Rock' }, { value: 'pop', label: 'Pop' }, { value: 'jazz', label: 'Jazz' }, { value: 'classical', label: 'Classical' }, { value: 'electronic', label: 'Electronic' }, { value: 'hiphop', label: 'Hip Hop' }, { value: 'country', label: 'Country' }, { value: 'blues', label: 'Blues' }, { value: 'reggae', label: 'Reggae' }, { value: 'folk', label: 'Folk' }, { value: 'metal', label: 'Metal' }, { value: 'indie', label: 'Indie' }, ] const skills: AutocompleteOption[] = [ { value: 'frontend', label: 'Frontend Development' }, { value: 'backend', label: 'Backend Development' }, { value: 'fullstack', label: 'Full Stack Development' }, { value: 'mobile', label: 'Mobile Development' }, { value: 'devops', label: 'DevOps' }, { value: 'testing', label: 'Testing & QA' }, { value: 'design', label: 'UI/UX Design' }, { value: 'pm', label: 'Project Management', disabled: true }, { value: 'data', label: 'Data Science' }, { value: 'ml', label: 'Machine Learning' }, ] export const Basic = { render: () => { const [selectedValues, setSelectedValues] = useState([]) return (
    ) }, } export const WithMaxTags = { render: () => { const [selectedValues, setSelectedValues] = useState([]) return (
    ) }, } export const WithPreselected = { render: () => { const [selectedValues, setSelectedValues] = useState(['javascript', 'typescript']) return (
    ) }, } export const WithDisabledOptions = { render: () => { const [selectedValues, setSelectedValues] = useState([]) return (
    ) }, } export const Disabled = { render: () => { const [selectedValues, setSelectedValues] = useState(['rock', 'jazz']) return (
    ) }, } export const WithError = { render: () => { const [selectedValues, setSelectedValues] = useState([]) return (
    ) }, } export const Interactive = { render: () => { const [frontendSkills, setFrontendSkills] = useState(['javascript']) const [backendSkills, setBackendSkills] = useState([]) const [genres, setGenres] = useState([]) const frontendOptions: AutocompleteOption[] = [ { value: 'html', label: 'HTML' }, { value: 'css', label: 'CSS' }, { value: 'javascript', label: 'JavaScript' }, { value: 'typescript', label: 'TypeScript' }, { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue.js' }, { value: 'angular', label: 'Angular' }, { value: 'svelte', label: 'Svelte' }, ] const backendOptions: AutocompleteOption[] = [ { value: 'nodejs', label: 'Node.js' }, { value: 'python', label: 'Python' }, { value: 'java', label: 'Java' }, { value: 'csharp', label: 'C#' }, { value: 'php', label: 'PHP' }, { value: 'ruby', label: 'Ruby' }, { value: 'go', label: 'Go' }, { value: 'rust', label: 'Rust' }, ] return (
    Developer Profile Setup
    Profile Summary
    Frontend:{' '} {frontendSkills.length > 0 ? frontendSkills.join(', ') : 'None'} Backend:{' '} {backendSkills.length > 0 ? backendSkills.join(', ') : 'None'} Music: {genres.length > 0 ? genres.join(', ') : 'None'}
    ) }, } export const AllStates = { render: () => { const [state1, setState1] = useState([]) const [state2, setState2] = useState(['rock', 'jazz']) const [state3, setState3] = useState([]) const [state4, setState4] = useState(['javascript']) return (
    Empty State
    With Selected Values
    With Error
    Disabled State
    ) }, } export const InDialog = { render: () => { const [isOpen, setIsOpen] = useState(false) const [selectedSkills, setSelectedSkills] = useState([]) const [selectedGenres, setSelectedGenres] = useState(['rock']) const handleSubmit = () => { console.log('Selected skills:', selectedSkills) console.log('Selected genres:', selectedGenres) setIsOpen(false) } const handleReset = () => { setSelectedSkills([]) setSelectedGenres([]) } return ( <> setIsOpen(false)}> Edit Your Profile Update your skills and music preferences
    ) }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Autocomplete/Autocomplete.tsx ================================================ import { clsx } from 'clsx' import { type ComponentProps, type KeyboardEvent, type ReactNode, useEffect, useRef, useState, } from 'react' import { useGetId } from '@/shared/hooks' import { ArrowDownIcon, DeleteIcon } from '@/shared/icons' import { IconButton } from '../IconButton' import { Typography } from '../Typography' import s from './Autocomplete.module.css' export type AutocompleteOption = { value: string label: string disabled?: boolean } export type AutocompleteProps = { label?: ReactNode placeholder?: string options: AutocompleteOption[] value: string[] onChange: (value: string[]) => void disabled?: boolean maxTags?: number errorMessage?: string className?: string } & Omit, 'onChange'> export const Autocomplete = ({ label, placeholder = 'Search and select...', options, value, onChange, disabled = false, maxTags, errorMessage, className, ...props }: AutocompleteProps) => { const [isOpen, setIsOpen] = useState(false) const [searchTerm, setSearchTerm] = useState('') const [focusedIndex, setFocusedIndex] = useState(-1) // For detecting clicks outside component to close dropdown const containerRef = useRef(null) // For programmatic focus management (Escape key, focus after selection) const inputRef = useRef(null) const id = useGetId(props.id) const filteredOptions = options.filter( (option) => option.label.toLowerCase().includes(searchTerm.toLowerCase()) && !value.includes(option.value) ) const isMaxTagsReached = maxTags ? value.length >= maxTags : false const showError = Boolean(errorMessage) // Close dropdown on outside click useEffect(() => { if (!isOpen) return const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false) setFocusedIndex(-1) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen]) // Handle keyboard navigation const handleKeyDown = (e: KeyboardEvent) => { if (disabled) return switch (e.key) { case 'ArrowDown': e.preventDefault() if (!isOpen) { setIsOpen(true) setFocusedIndex(0) } else { setFocusedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev)) } break case 'ArrowUp': e.preventDefault() setFocusedIndex((prev) => (prev > 0 ? prev - 1 : 0)) break case 'Enter': e.preventDefault() if (isOpen && focusedIndex >= 0 && filteredOptions[focusedIndex]) { selectOption(filteredOptions[focusedIndex]) } break case 'Escape': e.preventDefault() setIsOpen(false) setFocusedIndex(-1) inputRef.current?.blur() break case 'Backspace': if (!searchTerm && value.length > 0) { removeTag(value[value.length - 1]) } break } } const selectOption = (option: AutocompleteOption) => { if (option.disabled || isMaxTagsReached) return onChange([...value, option.value]) setSearchTerm('') setFocusedIndex(-1) inputRef.current?.focus() } const removeTag = (tagValue: string) => { onChange(value.filter((v) => v !== tagValue)) } const handleInputFocus = () => { if (!disabled) { setIsOpen(true) } } const handleInputChange = (e: React.ChangeEvent) => { setSearchTerm(e.target.value) setIsOpen(true) setFocusedIndex(-1) } const selectedOptions = options.filter((option) => value.includes(option.value)) return (
    {label && ( {label} )}
    {/* Selected tags */} {selectedOptions.map((option) => (
    {option.label} {!disabled && ( removeTag(option.value)} className={s.deleteButton} aria-label={`Remove ${option.label}`} type="button" tabIndex={-1}> )}
    ))} {/* Search input */}
    {/* Dropdown arrow */} !disabled && setIsOpen(!isOpen)} />
    {/* Dropdown */} {isOpen && !disabled && (
    {filteredOptions.length > 0 ? ( filteredOptions.map((option, index) => (
    !option.disabled && selectOption(option)} onMouseEnter={() => setFocusedIndex(index)}> {option.label}
    )) ) : (
    {searchTerm ? 'No options found' : 'All options selected'}
    )}
    )} {/* Error message */} {showError && ( {errorMessage} )} {/* Tags counter */} {maxTags && ( {value.length}/{maxTags} selected )}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Autocomplete/index.ts ================================================ export * from './Autocomplete' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Button/Button.module.css ================================================ .button { cursor: pointer; display: inline-flex; gap: 4px; align-items: center; justify-content: center; height: 40px; padding: 8px 16px; border-radius: 45px; font-size: var(--font-size-s); font-weight: 600; color: var(--color-text-primary); transition: opacity 200ms; } .button:focus-visible { outline: 2px solid var(--color-outline-focus); outline-offset: 2px; } .button:disabled { cursor: initial; background-color: var(--color-disabled); } .button:hover:not(:disabled), .button:focus:not(:disabled) { opacity: 0.8; } .primary { background-color: var(--color-accent); } .secondary { background-color: var(--color-bg-interactive-secondary); } .fullWidth { width: 100%; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Button/Button.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { Button } from './Button' const meta = { title: 'Components/Button', component: Button, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const AllButtons: Story = { render: () => (
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Button/Button.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ElementType } from 'react' import s from './Button.module.css' export type ButtonVariant = 'primary' | 'secondary' export type ButtonProps = { as?: T fullWidth?: boolean variant?: ButtonVariant } & ComponentProps export const Button = ({ as: Component = 'button', children, className, fullWidth = false, variant = 'primary', ...props }: ButtonProps) => { const classNames = clsx(s.button, s[variant], fullWidth && s.fullWidth, className) return ( {children} ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Button/index.ts ================================================ export * from './Button' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Card/Card.module.css ================================================ .card { display: flex; flex-direction: column; padding: 8px; background: var(--color-bg-card); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Card/Card.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { Typography } from '../Typography' import { Card } from './Card' const meta = { title: 'Components/Card', component: Card, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Basic: Story = { render: () => ( Chill Mix Julia Wolf, Khalid, ayokay and more ), } export const AsSection: Story = { render: () => ( Card as section You can use any tag via 'as' prop ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Card/Card.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ElementType, ReactNode } from 'react' import s from './Card.module.css' export type CardProps = { as?: T className?: string children?: ReactNode } & ComponentProps export const Card = ({ as: Component = 'div', className, children, ...props }: CardProps) => { return ( {children} ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Card/index.ts ================================================ export * from './Card' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Dialog/Dialog.module.css ================================================ .backdrop { position: fixed; z-index: 1; inset: 0; display: flex; align-items: center; justify-content: center; background-color: rgb(0 0 0 / 50%); animation: fade-in 200ms ease-out; } .dialog { overflow: hidden; display: flex; flex-direction: column; max-width: 745px; max-height: 90vh; border-radius: 4px; background-color: var(--color-bg-secondary); animation: slide-in 200ms ease-out; } .header { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 18px 24px; } .closeButton { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; font-size: 16px; color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .closeButton:hover { color: var(--color-text-primary); background-color: var(--color-bg-input-hover); } .content { overflow-y: auto; flex: 1; padding: 20px 24px; } .footer { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; padding: 18px 24px; } /* Animations */ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes slide-in { from { transform: translateY(-500px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } /* Responsive */ @media (width <= 768px) { .dialog { max-width: 95vw; margin: 20px; } .header, .content, .footer { padding-right: 16px; padding-left: 16px; } } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Dialog/Dialog.stories.tsx ================================================ import type { Meta } from '@storybook/react-vite' import { useState } from 'react' import { Button } from '../Button' import { TextField } from '../TextField' import { Typography } from '../Typography' import { Dialog, DialogContent, DialogFooter, DialogHeader } from './index' const meta = { title: 'Components/Dialog', component: Dialog, parameters: { layout: 'centered', }, } satisfies Meta export default meta export const BasicDialog = { render: () => { const [open, setOpen] = useState(false) return ( <> setOpen(false)}> Dialog Title This is dialog content. Here can be any content - text, forms, images and much more. ) }, } export const FormDialog = { render: () => { const [open, setOpen] = useState(false) return ( <> setOpen(false)}> Sign in to Spotifun
    ) }, } export const WithoutCloseButton = { render: () => { const [open, setOpen] = useState(false) return ( <> setOpen(false)}> Millions of songs. Free on Musicfun.
    😊
    ) }, } export const LongContent = { render: () => { const [open, setOpen] = useState(false) return ( <> setOpen(false)}> Long Content
    {Array.from({ length: 20 }, (_, i) => ( This is paragraph number {i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. ))}
    ) }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Dialog/Dialog.tsx ================================================ import { clsx } from 'clsx' import { createContext, type ReactNode, use, useEffect } from 'react' import { createPortal } from 'react-dom' import { IconButton } from '../IconButton' import s from './Dialog.module.css' type DialogContextType = { onClose?: () => void } const DialogContext = createContext(null) const useDialogContext = () => { const context = use(DialogContext) if (!context) { throw new Error('Dialog compound components must be used within Dialog component') } return context } export type DialogProps = { children: ReactNode open: boolean onClose?: () => void className?: string } export const Dialog = ({ children, open, onClose, className }: DialogProps) => { const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose?.() } } // Add global keydown handler for ESC key useEffect(() => { if (!open) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose?.() } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } }, [open, onClose]) if (!open) return null const dialogContent = (
    {children}
    ) return createPortal(dialogContent, document.body) } /* * DialogHeader */ export type DialogHeaderProps = { children?: ReactNode className?: string showCloseButton?: boolean } export const DialogHeader = ({ children, className, showCloseButton = true, }: DialogHeaderProps) => { const { onClose } = useDialogContext() return (
    {children}
    {showCloseButton && ( )}
    ) } /* * DialogContent */ export type DialogContentProps = { children: ReactNode className?: string } export const DialogContent = ({ children, className }: DialogContentProps) => { return
    {children}
    } /* * DialogFooter */ export type DialogFooterProps = { children: ReactNode className?: string } export const DialogFooter = ({ children, className }: DialogFooterProps) => { return
    {children}
    } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Dialog/index.ts ================================================ export * from './Dialog' ================================================ FILE: apps/react-effector-fsd/src/shared/components/DropdownMenu/DropdownMenu.module.css ================================================ .container { position: relative; display: inline-block; } .trigger { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; font-size: var(--font-size-s); color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .trigger:disabled { cursor: default; opacity: 0.5; } .trigger:enabled:hover, .trigger:enabled:focus-visible { color: var(--color-text-primary); background-color: var(--color-bg-input-hover); } .content { position: fixed; z-index: 50; min-width: 160px; padding: 4px; border-radius: 8px; background-color: var(--color-bg-primary); box-shadow: 0 10px 38px -10px rgb(22 23 24 / 35%), 0 10px 20px -15px rgb(22 23 24 / 20%); } .content.align-start { transform-origin: top left; } .content.align-center { transform-origin: top center; transform: translateX(-50%); } .content.align-end { transform-origin: top right; transform: translateX(-100%); } .content.side-top { transform-origin: bottom; } .content.side-top.align-center { transform: translateX(-50%) translateY(-100%); } .content.side-top.align-end { transform: translateX(-100%) translateY(-100%); } .content.side-top.align-start { transform: translateY(-100%); } .item { cursor: pointer; display: flex; gap: 8px; align-items: center; width: 100%; padding: 8px 12px; border: none; border-radius: 4px; font-size: var(--font-size-m); color: var(--color-text-primary); text-align: left; background: transparent; transition: all 200ms ease; } .item:focus-visible { background-color: var(--color-accent); outline: none; } .item:hover:not(:disabled) { background-color: var(--color-accent); } .itemDisabled { cursor: not-allowed; color: var(--color-text-secondary); opacity: 0.5; } .itemDisabled:hover { background: transparent; } .separator { height: 1px; margin: 4px 0; background-color: var(--color-border-base); } /* Animations */ @keyframes dropdown-menu-show { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } ================================================ FILE: apps/react-effector-fsd/src/shared/components/DropdownMenu/DropdownMenu.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { CreateIcon, MoreIcon, PlusIcon } from '@/shared/icons' import { IconButton } from '../IconButton' import { Typography } from '../Typography' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from './DropdownMenu' const meta: Meta = { title: 'Components/DropdownMenu', component: DropdownMenu, parameters: { layout: 'centered', }, } export default meta type Story = StoryObj export const BasicDropdownMenu: Story = { render: () => ( alert('Edit clicked!')}>Edit alert('Add to playlist clicked!')}> Add to playlist alert('Show text song clicked!')}> Show text song ), } export const WithIcons: Story = { args: {}, render: () => ( alert('Edit')}> Edit alert('Add to playlist')}> Add to playlist alert('Show text song')}>Show text song ), } export const WithDisabledItem: Story = { args: {}, render: () => ( alert('Edit')}>Edit Add to playlist (disabled) alert('Show text song')}>Show text song ), } export const CustomTrigger: Story = { args: {}, render: () => ( alert('Action 1')}>Action 1 alert('Action 2')}>Action 2 alert('Action 3')}>Action 3 ), } export const DifferentAlignments: Story = { args: {}, render: () => (
    Align Start Edit Add to playlist Show text song
    Align Center Edit Add to playlist Show text song
    Align End (default) Edit Add to playlist Show text song
    ), } export const WithLinks: Story = { args: {}, render: () => ( alert('Edit clicked')}> Edit console.log('Link clicked')}> Visit Website alert('Show text song')}>Show text song ), } export const Interactive: Story = { args: {}, render: () => (
    Click the menu buttons to test functionality
    console.log('Edit clicked')}> Edit track console.log('Add to playlist clicked')}> Add to playlist console.log('External link clicked')}> Show lyrics online console.log('Download clicked')}> Download Share (coming soon) console.log('Edit playlist')}> Edit playlist console.log('Share playlist')}> Share playlist console.log('Delete playlist')}> Delete playlist
    Open browser console to see click events
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/DropdownMenu/DropdownMenu.tsx ================================================ import { clsx } from 'clsx' import { type ComponentProps, createContext, type ElementType, type ReactNode, use, useEffect, useRef, useState, } from 'react' import { createPortal } from 'react-dom' import s from './DropdownMenu.module.css' type DropdownMenuContextType = { isOpen: boolean onClose: () => void onToggle: () => void triggerRef: React.RefObject } const DropdownMenuContext = createContext(null) const useDropdownMenuContext = () => { const context = use(DropdownMenuContext) if (!context) { throw new Error('DropdownMenu compound components must be used within DropdownMenu component') } return context } /* * DropdownMenu */ export type DropdownMenuProps = { children: ReactNode className?: string } export const DropdownMenu = ({ children, className }: DropdownMenuProps) => { const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef(null) const onClose = () => setIsOpen(false) const onToggle = () => setIsOpen(!isOpen) useBlockScroll({ isOpen, triggerRef }) // Close on escape key useEffect(() => { if (!isOpen) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen]) // Close on click outside useEffect(() => { if (!isOpen) return const handleClickOutside = (e: MouseEvent) => { const target = e.target as Element if ( triggerRef.current && !triggerRef.current.contains(target) && !target.closest('[data-dropdown-content]') ) { onClose() } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen]) const contextValue = { isOpen, onClose, onToggle, triggerRef, } return (
    {children}
    ) } /* * DropdownMenuTrigger */ export type DropdownMenuTriggerProps = { children: ReactNode className?: string asChild?: boolean } export const DropdownMenuTrigger = ({ children, className, asChild = false, }: DropdownMenuTriggerProps) => { const { onToggle, triggerRef } = useDropdownMenuContext() if (asChild) { return (
    } onClick={onToggle} className={className}> {children}
    ) } return ( ) } /* * DropdownMenuContent */ export type DropdownMenuContentProps = { children: ReactNode className?: string align?: 'start' | 'center' | 'end' side?: 'top' | 'bottom' | 'left' | 'right' } export const DropdownMenuContent = ({ children, className, align = 'end', side = 'bottom', }: DropdownMenuContentProps) => { const { isOpen, triggerRef } = useDropdownMenuContext() const [position, setPosition] = useState({ top: 0, left: 0 }) // it's needed to prevent flickering const [isPositioned, setIsPositioned] = useState(false) useEffect(() => { if (!isOpen || !triggerRef.current) { setIsPositioned(false) return } const triggerRect = triggerRef.current.getBoundingClientRect() const scrollTop = window.pageYOffset || document.documentElement.scrollTop const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft let top = 0 let left = 0 // Calculate position based on side switch (side) { case 'bottom': top = triggerRect.bottom + scrollTop + 4 break case 'top': top = triggerRect.top + scrollTop - 4 break case 'right': left = triggerRect.right + scrollLeft + 4 top = triggerRect.top + scrollTop break case 'left': left = triggerRect.left + scrollLeft - 4 top = triggerRect.top + scrollTop break } // Calculate position based on align if (side === 'bottom' || side === 'top') { switch (align) { case 'start': left = triggerRect.left + scrollLeft break case 'center': left = triggerRect.left + scrollLeft + triggerRect.width / 2 break case 'end': left = triggerRect.right + scrollLeft break } } setPosition({ top, left }) setIsPositioned(true) }, [isOpen, align, side]) if (!isOpen || !isPositioned) return null const content = (
    {children}
    ) return createPortal(content, document.body) } /* * DropdownMenuItem */ export type DropdownMenuItemProps = { as?: T children: ReactNode onClick?: () => void className?: string disabled?: boolean } & ComponentProps export const DropdownMenuItem = ({ as: Component = 'button', children, onClick, className, disabled = false, ...props }: DropdownMenuItemProps) => { const { onClose } = useDropdownMenuContext() const handleClick = () => { if (disabled) return onClick?.() onClose() } const isButton = Component === 'button' return ( {children} ) } /* * DropdownMenuSeparator */ export type DropdownMenuSeparatorProps = { className?: string } export const DropdownMenuSeparator = ({ className }: DropdownMenuSeparatorProps) => { return
    } /** * Block scroll when menu is open. */ const useBlockScroll = ({ isOpen, triggerRef, }: { isOpen: boolean triggerRef: React.RefObject }) => { // Block scroll when menu is open useEffect(() => { if (!isOpen || !triggerRef.current) return const originalScrollElements: Array<{ element: Element; overflow: string }> = [] // Find all scrollable parent elements const findScrollableParents = (element: Element) => { const scrollableElements: Element[] = [] let parent = element.parentElement while (parent && parent !== document.body) { const style = window.getComputedStyle(parent) const hasVerticalScroll = style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflow === 'auto' || style.overflow === 'scroll' if (hasVerticalScroll && parent.scrollHeight > parent.clientHeight) { scrollableElements.push(parent) } parent = parent.parentElement } return scrollableElements } // Block scroll on body const bodyOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' originalScrollElements.push({ element: document.body, overflow: bodyOverflow }) // Block scroll on scrollable parents const scrollableParents = findScrollableParents(triggerRef.current) scrollableParents.forEach((element) => { const originalOverflow = (element as HTMLElement).style.overflow ;(element as HTMLElement).style.overflow = 'hidden' originalScrollElements.push({ element, overflow: originalOverflow }) }) return () => { // Restore original overflow values originalScrollElements.forEach(({ element, overflow }) => { ;(element as HTMLElement).style.overflow = overflow }) } }, [isOpen]) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/DropdownMenu/index.ts ================================================ export * from './DropdownMenu' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Hashtag/Tag.module.css ================================================ .hashtag { cursor: pointer; display: inline-flex; align-items: center; justify-content: center; min-width: 73px; padding: 8px 12px; border: 1px solid var(--color-border-base); border-radius: 45px; font-size: var(--font-size-xxxs); font-weight: 500; color: var(--color-text-primary); text-decoration: none; background-color: var(--color-bg-primary); transition: all 200ms ease; } .hashtag:focus-visible { outline: 2px solid var(--color-outline-focus); outline-offset: 2px; } .hashtag:hover:not(:disabled) { background-color: var(--color-bg-input-hover); } .active { color: var(--color-bg-primary); background-color: var(--color-text-primary); } .active:hover:not(:disabled) { color: var(--color-bg-primary); opacity: 0.9; background-color: var(--color-text-primary); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Hashtag/Tag.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { Tag } from './Tag.tsx' const meta = { title: 'Components/Hashtag', component: Tag, parameters: { layout: 'centered', }, args: { tag: 'Playlists', }, } satisfies Meta export default meta type Story = StoryObj export const Default: Story = {} export const Active: Story = { args: { active: true, }, } export const AsLink: Story = { args: { as: 'a', href: 'https://www.google.com', target: '_blank', }, } export const AllHashtags: Story = { render: () => (
    Podcasts & shows
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Hashtag/Tag.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ElementType } from 'react' import s from './Tag.module.css' export type HashtagProps = { as?: T active?: boolean tag: string className?: string } & ComponentProps export const Tag = ({ as: Component = 'button', active = false, tag, className, ...props }: HashtagProps) => { const classNames = clsx(s.hashtag, active && s.active, className) return ( #{tag} ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Hashtag/index.ts ================================================ export * from './Tag.tsx' ================================================ FILE: apps/react-effector-fsd/src/shared/components/IconButton/IconButton.module.css ================================================ .button { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; font-size: var(--font-size-s); color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .button:disabled { cursor: default; opacity: 0.5; } .button:enabled:hover, .button:enabled:focus-visible { color: var(--color-text-primary); background-color: var(--color-bg-input-hover); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/IconButton/IconButton.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { DownloadIcon, HomeIcon, LikeIcon, MoreIcon, PlayIcon, PlusIcon, SearchIcon, } from '@/shared/icons' import { IconButton } from './IconButton' const meta = { title: 'Components/IconButton', component: IconButton, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Basic: Story = { args: { children: , 'aria-label': 'Play', }, } export const AllIcons = { render: () => (
    ), } export const Disabled: Story = { args: { children: , disabled: true, }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/IconButton/IconButton.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps } from 'react' import s from './IconButton.module.css' type IconButtonProps = { children: React.ReactNode } & ComponentProps<'button'> export const IconButton = ({ children, className, ...props }: IconButtonProps) => { return ( ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/IconButton/index.ts ================================================ export * from './IconButton' ================================================ FILE: apps/react-effector-fsd/src/shared/components/ImageUploader/ImageUploader.module.css ================================================ .container { width: 100%; } .dropZone { cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center; width: 100%; min-height: 280px; border: 2px dashed var(--color-border-input-primary); border-radius: 8px; background-color: var(--color-bg-secondary); transition: all 200ms ease; } .dropZone:hover, .dropZone:focus-within { border-color: var(--color-border-input-active); background-color: var(--color-bg-input-hover); } .dropZone.dragOver { border-color: var(--color-accent); background-color: var(--color-bg-input-hover); } .dropZone.hasPreview { border-color: var(--color-border-input-active); border-style: solid; } .dropZone.error { border-color: var(--color-text-error); } .hiddenInput { position: absolute; overflow: hidden; width: 1px; height: 1px; opacity: 0; clip: rect(0, 0, 0, 0); } .uploadContent { display: flex; flex-direction: column; gap: 12px; align-items: center; padding: 32px 16px; } .uploadIcon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; color: var(--color-text-secondary); background-color: var(--color-bg-primary); transition: all 200ms ease; } .dropZone:hover .uploadIcon, .dropZone:focus-within .uploadIcon { color: var(--color-accent); background-color: var(--color-bg-card); } .uploadText { font-weight: 500; color: var(--color-text-secondary); transition: color 200ms ease; } .dropZone:hover .uploadText { color: var(--color-text-primary); } .previewContainer { position: relative; width: 100%; height: 100%; } .previewImage { width: 100%; height: 100%; min-height: 200px; border-radius: 6px; object-fit: cover; } .removeButton { position: absolute; top: 8px; right: 8px; } .removeButton:hover { opacity: 1; background-color: var(--color-text-error); } .removeButton:focus-visible { outline: 2px solid var(--color-outline-focus); outline-offset: 2px; } .errorMessage { margin-top: 8px; } /* States for different sizes */ .dropZone.small { min-height: 120px; } .dropZone.large { min-height: 300px; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ImageUploader/ImageUploader.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { ImageUploader } from './ImageUploader' const meta = { title: 'Components/ImageUploader', component: ImageUploader, parameters: { layout: 'centered', }, args: { onImageSelect: () => {}, }, } satisfies Meta export default meta type Story = StoryObj export const Default: Story = { args: { placeholder: 'Upload Cover Image', }, render: (args) => (
    ), } export const CustomPlaceholder: Story = { args: { placeholder: 'Choose your avatar', }, render: (args) => (
    ), } export const WithCustomLimits: Story = { args: { placeholder: 'Upload image (max 2MB)', maxSizeInMB: 2, acceptedFormats: ['image/jpeg', 'image/png'], }, render: (args) => (
    ), } export const AllowAllImages: Story = { args: { placeholder: 'Upload any image format', acceptedFormats: ['image/*'], maxSizeInMB: 10, }, render: (args) => (
    ), } export const Interactive: Story = { render: () => (

    Profile Avatar

    console.log('Avatar selected:', file.name)} maxSizeInMB={1} />

    Playlist Cover

    console.log('Cover selected:', file.name)} maxSizeInMB={5} />
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ImageUploader/ImageUploader.tsx ================================================ import { clsx } from 'clsx' import { type ChangeEvent, type DragEvent, useRef, useState } from 'react' import { ImageUploadIcon } from '@/shared/icons' import { IconButton } from '../IconButton' import { Typography } from '../Typography' import s from './ImageUploader.module.css' export type ImageUploaderProps = { onImageSelect: (file: File) => void className?: string acceptedFormats?: string[] maxSizeInMB?: number placeholder?: string } export const ImageUploader = ({ className, onImageSelect, acceptedFormats = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'], maxSizeInMB = 5, placeholder = 'Upload Cover Image', }: ImageUploaderProps) => { const [isDragOver, setIsDragOver] = useState(false) const [preview, setPreview] = useState(null) const [error, setError] = useState(null) const fileInputRef = useRef(null) const validateFile = (file: File): string | null => { if (!acceptedFormats.includes(file.type)) { return `Only ${acceptedFormats.join(', ')} files are allowed` } const maxSizeInBytes = maxSizeInMB * 1024 * 1024 if (file.size > maxSizeInBytes) { return `File size must be less than ${maxSizeInMB}MB` } return null } const handleFileSelect = (file: File) => { const validationError = validateFile(file) if (validationError) { setError(validationError) setPreview(null) return } setError(null) // Create preview const reader = new FileReader() reader.onload = (e) => { setPreview(e.target?.result as string) } reader.readAsDataURL(file) onImageSelect(file) } const handleFileInputChange = (e: ChangeEvent) => { const file = e.target.files?.[0] if (file) { handleFileSelect(file) } } const handleDragOver = (e: DragEvent) => { e.preventDefault() setIsDragOver(true) } const handleDragLeave = (e: DragEvent) => { e.preventDefault() setIsDragOver(false) } const handleDrop = (e: DragEvent) => { e.preventDefault() setIsDragOver(false) const files = Array.from(e.dataTransfer.files) const imageFile = files.find((file) => file.type.startsWith('image/')) if (imageFile) { handleFileSelect(imageFile) } } const handleRemoveImage = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setPreview(null) setError(null) // Clear input value to allow selecting the same file again if (fileInputRef.current) { fileInputRef.current.value = '' } } return (
    {error && ( {error} )}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ImageUploader/index.ts ================================================ export * from './ImageUploader' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Pagination/Pagination.module.css ================================================ .pagination { display: flex; gap: 6px; align-items: center; } .navButton { width: 40px; height: 40px; border-radius: 4px; color: var(--color-text-primary); background-color: var(--color-bg-secondary); transition: all 200ms ease; } .navButton:disabled { cursor: not-allowed; opacity: 0.5; background-color: var(--color-bg-secondary); } .navButton:enabled:hover, .navButton:enabled:focus { background-color: var(--color-bg-input-hover); } .pageNumbers { display: flex; gap: 4px; align-items: center; } .pageButton { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border: none; border-radius: 8px; font-size: var(--font-size-m); font-weight: 500; color: var(--color-text-primary); background-color: var(--color-bg-secondary); transition: all 200ms ease; } .pageButton:focus-visible { outline: 2px solid var(--color-outline-focus); outline-offset: 2px; } .pageButton:hover:not(.active) { background-color: var(--color-bg-input-hover); } .pageButton.active { background-color: var(--color-accent); } .pageButton.active:hover { opacity: 0.9; } .ellipsis { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; font-size: var(--font-size-m); font-weight: 500; color: var(--color-text-secondary); } /* Responsive adjustments */ @media (width <= 480px) { .pagination { gap: 2px; } .navButton, .pageButton, .ellipsis { width: 36px; height: 36px; } .pageButton, .ellipsis { font-size: var(--font-size-s); } } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Pagination/Pagination.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Card } from '../Card' import { Typography } from '../Typography' import { Pagination } from './Pagination' const meta = { title: 'Components/Pagination', component: Pagination, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Basic: Story = { args: { page: 1, pagesCount: 3, onPageChange: () => {}, }, } export const MiddlePage: Story = { args: { page: 5, pagesCount: 10, onPageChange: () => {}, }, } export const LastPage: Story = { args: { page: 3, pagesCount: 3, onPageChange: () => {}, }, } export const ManyPages: Story = { args: { page: 8, pagesCount: 20, onPageChange: () => {}, }, } export const SinglePage: Story = { args: { page: 1, pagesCount: 1, onPageChange: () => {}, }, } export const Interactive = { render: () => { const [currentPage, setCurrentPage] = useState(1) const totalCount = 95 const pageSize = 10 const pagesCount = Math.ceil(totalCount / pageSize) const handlePageChange = (page: number) => { setCurrentPage(page) } return (
    Interactive Pagination Current page: {currentPage} Total items: {totalCount} Items per page: {pageSize} Click on page numbers or arrows to navigate
    ) }, } export const AllStates = { render: () => (
    First Page (3 pages total) {}} />
    Middle Page (10 pages total) {}} />
    Last Page (3 pages total) {}} />
    Many Pages (20 pages total) {}} />
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Pagination/Pagination.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps } from 'react' import { KeyboardArrowLeftIcon, KeyboardArrowRightIcon } from '@/shared/icons' import { IconButton } from '../IconButton' import s from './Pagination.module.css' export type PaginationProps = { page: number pagesCount: number onPageChange: (page: number) => void className?: string } & Omit, 'children'> const MAX_VISIBLE_PAGES = 5 export const Pagination = ({ page, pagesCount, onPageChange, className, ...props }: PaginationProps) => { // Helper function to generate page numbers array const generatePageNumbers = () => { const pages: (number | 'ellipsis')[] = [] if (pagesCount <= MAX_VISIBLE_PAGES) { // Show all pages if total is small for (let i = 1; i <= pagesCount; i++) { pages.push(i) } } else { // Always show first page pages.push(1) if (page > 3) { pages.push('ellipsis') } // Show pages around current page const start = Math.max(2, page - 1) const end = Math.min(pagesCount - 1, page + 1) for (let i = start; i <= end; i++) { if (i !== 1 && i !== pagesCount) { pages.push(i) } } if (page < pagesCount - 2) { pages.push('ellipsis') } // Always show last page if it's not already included if (pagesCount > 1) { pages.push(pagesCount) } } return pages } const handlePrevious = () => { if (page > 1) { onPageChange(page - 1) } } const handleNext = () => { if (page < pagesCount) { onPageChange(page + 1) } } const handlePageClick = (pageNumber: number) => { onPageChange(pageNumber) } if (pagesCount <= 1) { return null } const pageNumbers = generatePageNumbers() return (
    {/* Previous button */} {/* Page numbers */}
    {pageNumbers.map((pageNumber, index) => { if (pageNumber === 'ellipsis') { return ( ) } const isActive = pageNumber === page return ( ) })}
    {/* Next button */}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Pagination/index.ts ================================================ export * from './Pagination' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Progress/Progress.module.css ================================================ .progress { overflow: hidden; width: 100%; height: 4px; border-radius: 4px; background-color: var(--color-border-base); } .progressBar { height: 100%; border-radius: 4px; background-color: var(--color-accent); transition: width 300ms ease; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Progress/Progress.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Button } from '../Button' import { Card } from '../Card' import { Typography } from '../Typography' import { Progress } from './Progress' const meta = { title: 'Components/Progress', component: Progress, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Basic: Story = { args: { value: 75, }, render: (args) => (
    ), } export const CustomMax: Story = { args: { value: 15, max: 20, }, render: (args) => (
    ), } export const Empty: Story = { args: { value: 0, }, render: (args) => (
    ), } export const Full: Story = { args: { value: 100, }, render: (args) => (
    ), } export const AllStates = { render: () => (
    Empty (0%)
    Low (25%)
    Medium (50%)
    High (85%)
    Complete (100%)
    ), } export const CustomSizes = { render: () => (
    Small (height: 4px)
    Default (height: 8px)
    Large (height: 12px)
    ), } export const Interactive = { render: () => { const [progress, setProgress] = useState(0) const handleIncrease = () => { setProgress((prev) => Math.min(prev + 10, 100)) } const handleDecrease = () => { setProgress((prev) => Math.max(prev - 10, 0)) } const handleReset = () => { setProgress(0) } return (
    Interactive Progress
    Current progress: {progress}%
    ) }, } export const FileUploadExample = { render: () => (
    File Upload Progress
    image.jpg 75%
    document.pdf 100%
    video.mp4 32%
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Progress/Progress.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps } from 'react' import s from './Progress.module.css' export type ProgressProps = { value: number max?: number } & ComponentProps<'div'> export const Progress = ({ value, max = 100, className, ...props }: ProgressProps) => { const percentage = Math.min(Math.max((value / max) * 100, 0), 100) return (
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Progress/index.ts ================================================ export * from './Progress' ================================================ FILE: apps/react-effector-fsd/src/shared/components/ReactionButtons/ReactionButtons.module.css ================================================ .container { display: flex; gap: 8px; align-items: start; } .button { width: 28px; height: 28px; padding: 0; transition: color 200ms ease; } .button.large { width: 40px; height: 40px; } .button.liked { color: var(--color-accent); } .button.disliked { color: var(--color-accent); } .button:enabled:hover:is(.liked, .disliked), .button:enabled:focus:is(.liked, .disliked) { color: var(--color-accent); background-color: var(--color-bg-input-hover); } .likesCountBox { display: flex; flex-direction: column; align-items: center; } .likesCount { font-size: 10px; color: var(--color-text-secondary); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ReactionButtons/ReactionButtons.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Card } from '../Card' import { Typography } from '../Typography' import { type CurrentUserReaction, ReactionButtons } from './ReactionButtons' const meta = { title: 'Components/ReactionButtons', component: ReactionButtons, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Default: Story = { args: { reaction: 0, onLike: () => console.log('Liked!'), onDislike: () => console.log('Disliked!'), }, } export const WithLikesCount: Story = { args: { reaction: 0, onLike: () => console.log('Liked!'), onDislike: () => console.log('Disliked!'), likesCount: 10, }, } export const LikedState: Story = { args: { reaction: 1, onLike: () => console.log('Unlike'), onDislike: () => console.log('Disliked!'), }, } export const DislikedState: Story = { args: { reaction: -1, onLike: () => console.log('Liked!'), onDislike: () => console.log('Remove dislike'), }, } export const Interactive = { render: () => { const [reaction, setReaction] = useState(0) const handleLike = () => { setReaction(reaction === 1 ? 0 : 1) } const handleDislike = () => { setReaction(reaction === -1 ? 0 : -1) } return ( Interactive Reaction Buttons Try clicking the buttons below:
    Status: {reaction === 1 ? '👍 Liked' : reaction === -1 ? '👎 Disliked' : '😐 Neutral'}
    ) }, } export const AllStates = { render: () => (
    Default {}} onDislike={() => {}} />
    Liked {}} onDislike={() => {}} />
    Disliked {}} onDislike={() => {}} />
    With likes count {}} onDislike={() => {}} likesCount={10} />
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ReactionButtons/ReactionButtons.tsx ================================================ import { clsx } from 'clsx' import type { ReactionValue } from '@/shared/api/schema.ts' import { DislikeIcon, LikeIcon, LikeIconFill } from '@/shared/icons' import { IconButton } from '../IconButton' import s from './ReactionButtons.module.css' // duplication of the CurrentUserReaction type to decouple the shared layer from the features layer export type CurrentUserReaction = ReactionValue export type ReactionButtonsProps = { reaction?: CurrentUserReaction onLike: () => void onDislike: () => void likesCount?: number className?: string size?: keyof typeof SIZE_MAP } const SIZE_MAP = { small: 28, large: 40, } export const ReactionButtons = ({ reaction = 0, onLike, onDislike, likesCount, className, size = 'small', }: ReactionButtonsProps) => { const isLiked = reaction === 1 const isDisliked = reaction === -1 const iconSize = SIZE_MAP[size] return (
    { e.preventDefault() onLike() }} className={clsx(s.button, isLiked && s.liked, size === 'large' && s.large)} aria-label={isLiked ? 'Remove like' : 'Like'} type="button"> {isLiked ? ( ) : ( )} {likesCount}
    { e.preventDefault() onDislike() }} className={clsx(s.button, isDisliked && s.disliked, size === 'large' && s.large)} aria-label={isDisliked ? 'Remove dislike' : 'Dislike'} type="button">
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/ReactionButtons/index.ts ================================================ export * from './ReactionButtons' ================================================ FILE: apps/react-effector-fsd/src/shared/components/SearchField/SearchField.module.css ================================================ .inputWrapper { position: relative; display: flex; align-items: center; } .searchIcon { pointer-events: none; position: absolute; z-index: 1; left: 12px; color: var(--color-text-secondary); transition: color 200ms ease; } .input { width: 100%; height: 52px; padding: 15px 16px 15px 62px; border: 1px solid var(--color-border-input-primary); border-radius: 26px; font-size: var(--font-size-m); color: var(--color-text-primary-reverse); background-color: var(--color-bg-primary-reverse); outline: none; transition: 200ms background-color, 200ms color, 200ms border-color; } .input::placeholder { font-size: var(--font-size-m); color: var(--color-text-secondary); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/SearchField/SearchField.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { SearchField } from './SearchField' const meta = { title: 'Components/SearchField', component: SearchField, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Basic: Story = { args: { placeholder: 'Search for playlists...', }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/SearchField/SearchField.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import { SearchIcon } from '@/shared/icons' import s from './SearchField.module.css' export type SearchFieldProps = { label?: ReactNode placeholder?: string } & ComponentProps<'input'> export const SearchField = ({ className, placeholder = 'Search...', ...props }: SearchFieldProps) => { return (
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/SearchField/index.ts ================================================ export * from './SearchField' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Select/Select.module.css ================================================ .container { display: flex; flex-direction: column; width: 100%; } .selectWrapper { position: relative; width: 100%; } .select { width: 100%; height: 40px; padding: 8px 36px 8px 12px; border: none; font-size: var(--font-size-m); color: var(--color-text-primary); text-decoration: underline; text-underline-offset: 3px; appearance: none; background-color: transparent; outline: none; transition: 200ms background-color, 200ms color; } .select:disabled { cursor: not-allowed; color: var(--color-disabled); } .select:focus-visible { background-color: var(--color-bg-input-hover); } .select:hover:not(:disabled) { background-color: var(--color-bg-input-hover); } .select.error { border-color: var(--color-text-error); } /* Style dropdown options */ .select option { padding: 8px 12px; font-size: var(--font-size-m); color: var(--color-text-primary); background-color: var(--color-bg-secondary); transition: background-color 200ms ease; } .select option:hover { background-color: var(--color-bg-input-hover); } .select option:checked { font-weight: 600; color: var(--color-accent); background-color: var(--color-bg-input-hover); } .select option:disabled { color: var(--color-disabled); } /* Custom dropdown icon */ .icon { pointer-events: none; position: absolute; top: 50%; right: 12px; transform: translateY(-50%); width: 20px; height: 20px; color: var(--color-text-secondary); transition: color 200ms ease, transform 200ms ease; } /* Rotate icon when dropdown is open */ .select:open + .icon { transform: translateY(-50%) rotate(180deg); } .label.error { color: var(--color-text-error); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Select/Select.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { Select } from './Select' const meta = { title: 'Components/Select', component: Select, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj const commonOptions = [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue.js' }, { value: 'angular', label: 'Angular' }, { value: 'svelte', label: 'Svelte' }, { value: 'vanilla', label: 'Vanilla JS' }, ] const genres = [ { value: 'pop', label: 'Pop' }, { value: 'rock', label: 'Rock' }, { value: 'jazz', label: 'Jazz' }, { value: 'classical', label: 'Classical' }, { value: 'electronic', label: 'Electronic' }, { value: 'hip-hop', label: 'Hip Hop' }, { value: 'country', label: 'Country' }, ] export const AllVariants = { render: () => (
    ), } export const Basic: Story = { args: { label: 'Choose framework', placeholder: 'Select a framework', options: commonOptions, }, render: (args) => (
    ), } export const Disabled: Story = { args: { label: 'Framework (disabled)', placeholder: 'Cannot select', options: commonOptions, disabled: true, }, render: (args) => (
    ), } export const WithDisabledOptions: Story = { args: { label: 'Music Genre', placeholder: 'Choose your favorite genre', options: [ { value: 'pop', label: 'Pop' }, { value: 'rock', label: 'Rock' }, { value: 'jazz', label: 'Jazz (Coming Soon)', disabled: true }, { value: 'classical', label: 'Classical' }, { value: 'electronic', label: 'Electronic (Coming Soon)', disabled: true }, { value: 'hip-hop', label: 'Hip Hop' }, ], }, render: (args) => (
    setValue(e.target.value)} />
    Selected value: {value || 'None'}
    ) }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Select/Select.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import { ArrowDownIcon } from '@/shared/icons' import { useGetId } from '../../hooks/useGetId' import { Typography } from '../Typography' import s from './Select.module.css' export type SelectOption = { value: string label: string disabled?: boolean } export type SelectProps = { label?: ReactNode errorMessage?: string options: SelectOption[] placeholder?: string } & ComponentProps<'select'> export const Select = ({ className, errorMessage, id, label, options, placeholder, ...props }: SelectProps) => { const showError = Boolean(errorMessage) const selectId = useGetId(id) return (
    {label && ( {label} )}
    {showError && {errorMessage}}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Select/index.ts ================================================ export * from './Select' ================================================ FILE: apps/react-effector-fsd/src/shared/components/SortSelect/Select.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import { ArrowDownIcon } from '@/shared/icons' import { useGetId } from '../../hooks/useGetId' import { Typography } from '../Typography' import s from './Select.module.css' export type SelectOption = { value: string label: string disabled?: boolean } export type SelectProps = { label?: ReactNode errorMessage?: string options: SelectOption[] placeholder?: string } & ComponentProps<'select'> export const Select = ({ className, errorMessage, id, label, options, placeholder, ...props }: SelectProps) => { const showError = Boolean(errorMessage) const selectId = useGetId(id) return (
    {label && ( {label} )}
    {showError && {errorMessage}}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Table/Table.module.css ================================================ .table { table-layout: fixed; border-collapse: collapse; width: 100%; background: transparent; } .tableHead { border-bottom: 1px solid var(--color-border-base); } .tableHeaderCell { padding: 10px; border: none; font-size: var(--font-size-xs); font-weight: 500; color: var(--color-text-secondary); text-align: left; text-transform: uppercase; background: transparent; } .tableHeaderCell:first-child { padding-left: 16px; } .tableHeaderCell:last-child { padding-right: 16px; } .tableBody { background: transparent; } .tableRow { transition: background-color 200ms ease; } .tableBody .tableRow:hover { background-color: var(--color-bg-input-hover); } .tableCell { padding: 10px; border: none; font-size: var(--font-size-m); color: var(--color-text-primary); vertical-align: middle; background: transparent; } .tableCell:first-child { padding-left: 16px; } .tableCell:last-child { padding-right: 16px; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Table/Table.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { ReactionButtons } from '../ReactionButtons' import { Typography } from '../Typography' import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './Table' import s from './Table.module.css' const meta = { title: 'Components/Table', component: Table, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj const trackData = [ { id: 1, title: 'Play It Safe', artist: 'Julia Wolf', image: 'https://picsum.photos/40/40?random=1', dateAdded: '1 day ago', duration: '2:12', }, { id: 2, title: 'Ocean Front Apt.', artist: 'ayokay', image: 'https://picsum.photos/40/40?random=2', dateAdded: '1 day ago', duration: '2:12', }, { id: 3, title: 'Free Spirit', artist: 'Khalid', image: 'https://picsum.photos/40/40?random=3', dateAdded: '2 day ago', duration: '3:02', }, { id: 4, title: 'Remind You', artist: 'FRENSHIP', image: 'https://picsum.photos/40/40?random=4', dateAdded: '3 day ago', duration: '4:25', }, ] export const BasicTable = { render: () => (
    Name Email Role John Doe john@example.com Admin Jane Smith jane@example.com User Bob Johnson bob@example.com Editor
    ), } export const EmptyTable = { render: () => (
    Column 1 Column 2 Column 3 No data available
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Table/Table.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import s from './Table.module.css' /* * Table */ export type TableProps = { children: ReactNode className?: string } & ComponentProps<'table'> export const Table = ({ children, className, ...props }: TableProps) => { return ( {children}
    ) } /* * TableHead */ export type TableHeadProps = { children: ReactNode className?: string } & ComponentProps<'thead'> export const TableHead = ({ children, className, ...props }: TableHeadProps) => { return ( {children} ) } /* * TableBody */ export type TableBodyProps = { children: ReactNode className?: string } & ComponentProps<'tbody'> export const TableBody = ({ children, className, ...props }: TableBodyProps) => { return ( {children} ) } /* * TableRow */ export type TableRowProps = { children: ReactNode className?: string } & ComponentProps<'tr'> export const TableRow = ({ children, className, ...props }: TableRowProps) => { return ( {children} ) } /* * TableHeaderCell */ export type TableHeaderCellProps = { children?: ReactNode className?: string } & ComponentProps<'th'> export const TableHeaderCell = ({ children, className, ...props }: TableHeaderCellProps) => { return ( {children} ) } /* * TableCell */ export type TableCellProps = { children: ReactNode className?: string } & ComponentProps<'td'> export const TableCell = ({ children, className, ...props }: TableCellProps) => { return ( {children} ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Table/index.ts ================================================ export * from './Table' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Tabs/Tabs.module.css ================================================ .tabsList { display: flex; width: 100%; border-bottom: 1px solid var(--color-text-secondary); } .tabsTrigger { cursor: pointer; position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; padding: 12px 16px; border: none; font-size: var(--font-size-m); font-weight: 500; color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .tabsTrigger:focus-visible { outline: 2px solid var(--color-outline-focus); outline-offset: 2px; } .tabsTrigger:not(.active, :disabled):hover { opacity: 0.7; } .tabsTrigger.active { color: var(--color-accent); } .tabsTrigger.active::after { content: ''; position: absolute; bottom: -1px; left: 0; width: 100%; height: 2px; background-color: var(--color-accent); } .tabsTrigger.disabled { cursor: default; color: var(--color-disabled); } .tabsContent { padding: 32px 0; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Tabs/Tabs.stories.tsx ================================================ import type { Meta } from '@storybook/react-vite' import { useState } from 'react' import { Button } from '../Button' import { Card } from '../Card' import { Typography } from '../Typography' import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs' const meta = { title: 'Components/Tabs', component: Tabs, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta export const BasicTabs = { render: () => (
    Account Password Make changes to your account here. Change your password here.
    ), } export const ControlledTabs = { render: () => { const [activeTab, setActiveTab] = useState('tab1') return (
    Tab 1 Tab 2 Tab 3 First Tab Content This is content for the first tab. You can put any React content here. Second Tab Content This is content for the second tab with different information. Third Tab Content And this is the third tab with its own unique content. Active tab: {activeTab}
    ) }, } export const DisabledTab = { render: () => (
    Available Disabled Another This tab is available and active. This content should not be visible. This is another available tab.
    ), } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Tabs/Tabs.tsx ================================================ import { clsx } from 'clsx' import { type ComponentProps, createContext, type ReactNode, use, useState } from 'react' import s from './Tabs.module.css' type TabsContextType = { value?: string onValueChange?: (value: string) => void } const TabsContext = createContext(null) const useTabsContext = () => { const context = use(TabsContext) if (!context) { throw new Error('Tabs compound components must be used within Tabs component') } return context } /* * Tabs */ export type TabsProps = { children: ReactNode defaultValue?: string value?: string onValueChange?: (value: string) => void } & ComponentProps<'div'> export const Tabs = ({ children, defaultValue, value: controlledValue, onValueChange, className, ...props }: TabsProps) => { const [internalValue, setInternalValue] = useState(defaultValue) const isControlled = controlledValue !== undefined const value = isControlled ? controlledValue : internalValue const handleValueChange = (newValue: string) => { if (!isControlled) { setInternalValue(newValue) } onValueChange?.(newValue) } return (
    {children}
    ) } /* * TabsList */ export type TabsListProps = { children: ReactNode className?: string } export const TabsList = ({ children, className }: TabsListProps) => { return
    {children}
    } /* * TabsTrigger */ export type TabsTriggerProps = { children: ReactNode value: string className?: string disabled?: boolean } export const TabsTrigger = ({ children, value, className, disabled }: TabsTriggerProps) => { const { value: activeValue, onValueChange } = useTabsContext() const isActive = activeValue === value const handleClick = () => { if (!disabled) { onValueChange?.(value) } } return ( ) } /* * TabsContent */ export type TabsContentProps = { children: ReactNode value: string className?: string } export const TabsContent = ({ children, value, className }: TabsContentProps) => { const { value: activeValue } = useTabsContext() const isActive = activeValue === value if (!isActive) return null return
    {children}
    } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Tabs/index.ts ================================================ export * from './Tabs' ================================================ FILE: apps/react-effector-fsd/src/shared/components/TagEditor/TagEditor.module.css ================================================ .container { display: flex; flex-direction: column; } .tagsContainer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding: 8px 0; } .tag { display: flex; gap: 6px; align-items: center; padding: 4px 8px; border: 1px solid var(--color-border-input-primary); border-radius: 16px; background-color: var(--color-bg-secondary); transition: all 200ms ease; } .tag:hover { background-color: var(--color-bg-input-hover); } .tagText { font-size: var(--font-size-s); font-weight: 500; color: var(--color-text-primary); white-space: nowrap; } .deleteButton { width: 16px; height: 16px; padding: 0; font-size: 10px; color: var(--color-text-secondary); background: transparent; transition: all 200ms ease; } .deleteButton:disabled { cursor: not-allowed; opacity: 0.5; } .deleteButton:enabled:hover { color: var(--color-text-error); background-color: transparent; } .counter { margin-top: 8px; color: var(--color-text-secondary); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TagEditor/TagEditor.stories.tsx ================================================ import type { Meta } from '@storybook/react-vite' import { useState } from 'react' import { Card } from '../Card' import { Typography } from '../Typography' import { TagEditor } from './TagEditor' const meta = { title: 'Components/TagEditor', component: TagEditor, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta export const Basic = { render: () => { const [tags, setTags] = useState([]) return (
    ) }, } export const WithMaxTags = { render: () => { const [tags, setTags] = useState([]) return (
    ) }, } export const Disabled = { render: () => { const [tags, setTags] = useState(['React', 'TypeScript']) return (
    ) }, } export const PrefilledTags = { render: () => { const [tags, setTags] = useState([ 'JavaScript', 'TypeScript', 'React', 'Node.js', 'CSS', 'HTML', ]) return (
    ) }, } export const Interactive = { render: () => { const [frontendTags, setFrontendTags] = useState(['React', 'Vue.js']) const [backendTags, setBackendTags] = useState(['Node.js']) return (
    Frontend Technologies
    Backend Technologies
    Summary: Frontend: {frontendTags.length > 0 ? frontendTags.join(', ') : 'None'} Backend: {backendTags.length > 0 ? backendTags.join(', ') : 'None'}
    ) }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TagEditor/TagEditor.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, KeyboardEvent } from 'react' import { useState } from 'react' import { DeleteIcon } from '@/shared/icons' import { IconButton } from '../IconButton' import { TextField } from '../TextField' import { Typography } from '../Typography' import s from './TagEditor.module.css' export type TagEditorProps = { label?: string placeholder?: string value: string[] onTagsChange: (tags: string[]) => void maxTags?: number disabled?: boolean } & ComponentProps<'div'> export const TagEditor = ({ label, placeholder = 'Add tag and press Enter', value, onTagsChange, className, maxTags, disabled = false, ...props }: TagEditorProps) => { const [inputValue, setInputValue] = useState('') const addTag = (tag: string) => { const trimmedTag = tag.trim() if (!trimmedTag) return if (value.includes(trimmedTag)) return if (maxTags && value.length >= maxTags) return onTagsChange([...value, trimmedTag]) setInputValue('') } const removeTag = (tagToRemove: string) => { onTagsChange(value.filter((tag) => tag !== tagToRemove)) } const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() addTag(inputValue) } if (e.key === 'Backspace' && !inputValue && value.length > 0) { removeTag(value[value.length - 1]) } } const isMaxTagsReached = maxTags ? value.length >= maxTags : false return (
    setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={isMaxTagsReached ? 'Max tags reached' : placeholder} disabled={disabled} /> {value.length > 0 && (
      {value.map((tag) => (
    • {tag} removeTag(tag)} className={s.deleteButton} disabled={disabled} aria-label={`Remove tag ${tag}`} type="button">
    • ))}
    )} {maxTags && ( {value.length}/{maxTags} tags )}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TagEditor/index.ts ================================================ export * from './TagEditor' ================================================ FILE: apps/react-effector-fsd/src/shared/components/TextField/TextField.module.css ================================================ .box { display: flex; flex-direction: column; width: 100%; } .inputWrapper { position: relative; display: flex; align-items: center; } .icon { position: absolute; top: 50%; left: 12px; transform: translateY(-50%); display: flex; color: var(--color-text-secondary); } .input { width: 100%; height: 40px; padding: 8px 12px; border: 1px solid var(--color-border-input-primary); border-radius: 4px; font-size: var(--font-size-m); color: var(--color-text-primary); background-color: var(--color-bg-primary); outline: none; transition: 200ms background-color, 200ms color, 200ms border-color; } .input.large { height: 56px; } .input:disabled { color: var(--color-disabled); } .input:focus, .input:active:enabled { border-color: var(--color-border-input-active); } .input:hover:not(:disabled) { background-color: var(--color-bg-input-hover); } .input::placeholder { color: var(--color-text-secondary); } .input.error { border-color: var(--color-text-error); } .input.withIcon { padding-left: 40px; } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TextField/TextField.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { SearchIcon } from '@/shared/icons' import { TextField } from './TextField' const meta = { title: 'Components/TextField', component: TextField, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Primary: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', }, } export const Disabled: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', disabled: true, }, } export const Error: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', errorMessage: 'Some error message', }, } export const Search: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', icon: , inputSize: 'l', }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TextField/TextField.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import { useGetId } from '../../hooks/useGetId' import { Typography } from '../Typography' import s from './TextField.module.css' export type TextFieldSize = 'm' | 'l' export type TextFieldProps = { errorMessage?: string label?: ReactNode icon?: ReactNode inputSize?: TextFieldSize } & ComponentProps<'input'> export const TextField = ({ className, errorMessage, id, icon, label, inputSize = 'm', ...props }: TextFieldProps) => { const showError = Boolean(errorMessage) const inputId = useGetId(id) return (
    {label && ( {label} )}
    {icon && {icon}}
    {showError && {errorMessage}}
    ) } ================================================ FILE: apps/react-effector-fsd/src/shared/components/TextField/index.ts ================================================ export * from './TextField' ================================================ FILE: apps/react-effector-fsd/src/shared/components/Textarea/Textarea.module.css ================================================ .box { display: flex; flex-direction: column; width: 100%; } .textarea { resize: none; width: 100%; padding: 8px 12px; border: 1px solid var(--color-border-input-primary); border-radius: 4px; font-size: var(--font-size-m); color: var(--color-text-primary); background-color: var(--color-bg-primary); outline: none; transition: 200ms background-color, 200ms color, 200ms border-color; } .textarea:disabled { color: var(--color-disabled); } .textarea:focus, .textarea:active:enabled { border-color: var(--color-border-input-active); } .textarea:hover:not(:disabled) { background-color: var(--color-bg-input-hover); } .textarea::placeholder { color: var(--color-text-secondary); } .textarea.error { border-color: var(--color-text-error); } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Textarea/Textarea.stories.tsx ================================================ import type { Meta, StoryObj } from '@storybook/react-vite' import { Textarea } from './Textarea' const meta = { title: 'Components/Textarea', component: Textarea, parameters: { layout: 'centered', }, args: {}, } satisfies Meta export default meta type Story = StoryObj export const Primary: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', }, } export const Disabled: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', disabled: true, }, } export const Error: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', errorMessage: 'Some error message', }, } export const WithRows: Story = { args: { label: 'Some label', placeholder: 'Some placeholder', rows: 5, }, } ================================================ FILE: apps/react-effector-fsd/src/shared/components/Textarea/Textarea.tsx ================================================ import { clsx } from 'clsx' import type { ComponentProps, ReactNode } from 'react' import { useGetId } from '../../hooks/useGetId' import { Typography } from '../Typography' import s from './Textarea.module.css' export type TextareaProps = { errorMessage?: string label?: ReactNode } & ComponentProps<'textarea'> export const Textarea = ({ className, errorMessage, id, label, ...props }: TextareaProps) => { const showError = Boolean(errorMessage) const textareaId = useGetId(id) return (
    {label && ( {label} )}

    {errors.description &&

    {errors.description.message}

    } {errors.root?.server &&

    {errors.root?.server.message}

    } ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/features/playlists/delete-playlist/api/use-delete-mutation.ts ================================================ import { useMutation, useQueryClient } from '@tanstack/react-query' import { client } from '../../../../shared/api/client.ts' import { playlistsKeys } from '../../../../shared/api/keys-factories/playlists-keys-factory.ts' import type { SchemaGetPlaylistsOutput } from '../../../../shared/api/schema.ts' export const useDeleteMutation = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: async (playlistId: string) => { const response = await client.DELETE('/playlists/{playlistId}', { params: { path: { playlistId } }, }) return response.data }, onSuccess: (_, playlistId) => { queryClient.setQueriesData( { queryKey: playlistsKeys.lists() }, (oldData: SchemaGetPlaylistsOutput) => { return { ...oldData, data: oldData.data.filter((p) => p.id !== playlistId), } } ) queryClient.removeQueries({ queryKey: playlistsKeys.detail(playlistId) }) }, }) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/features/playlists/delete-playlist/ui/delete-playlist.tsx ================================================ import { useDeleteMutation } from '../api/use-delete-mutation.ts' type Props = { playlistId: string onDeleted: (playlistId: string) => void } export const DeletePlaylist = ({ playlistId, onDeleted }: Props) => { const { mutate } = useDeleteMutation() const handleDeleteClick = () => { mutate(playlistId) onDeleted?.(playlistId) } return } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/features/playlists/edit-playlist/api/use-playlist-query.tsx ================================================ import { useQuery } from '@tanstack/react-query' import { client } from '../../../../shared/api/client.ts' export const usePlaylistQuery = (playlistId: string | null) => { return useQuery({ queryKey: ['playlists', 'details', playlistId], queryFn: async () => { const response = await client.GET('/playlists/{playlistId}', { params: { path: { playlistId: playlistId! } }, }) return response.data! }, enabled: !!playlistId, }) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/features/playlists/edit-playlist/api/use-update-playlist-mutation.ts ================================================ import { useMutation, useQueryClient } from '@tanstack/react-query' import { client } from '../../../../shared/api/client.ts' import { playlistsKeys } from '../../../../shared/api/keys-factories/playlists-keys-factory.ts' import type { SchemaGetPlaylistsOutput, SchemaUpdatePlaylistRequestPayload, } from '../../../../shared/api/schema.ts' import type { JsonApiErrorDocument } from '../../../../shared/util/json-api-error.ts' type MutationVariables = SchemaUpdatePlaylistRequestPayload & { playlistId: string } export const useUpdatePlaylistMutation = ({ onSuccess, onError, }: { onSuccess?: () => void onError?: (error: JsonApiErrorDocument) => void }) => { const queryClient = useQueryClient() const key = playlistsKeys.myList() return useMutation({ mutationFn: async (variables: MutationVariables) => { const { playlistId, ...rest } = variables const response = await client.PUT('/playlists/{playlistId}', { params: { path: { playlistId: playlistId } }, body: { ...rest, tagIds: [] }, }) return response.data }, onMutate: async (variables: MutationVariables) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: playlistsKeys.all }) // Snapshot the previous value const previousMyPlaylists = queryClient.getQueryData(key) // Optimistically update to the new value queryClient.setQueryData(key, (oldData: SchemaGetPlaylistsOutput) => { return { ...oldData, data: oldData.data.map((p) => { if (p.id === variables.playlistId) return { ...p, attributes: { ...p.attributes, description: variables.description, title: variables.title, }, } else return p }), } }) // Return a context with the previous and new todo return { previousMyPlaylists } }, // If the mutation fails, use the context we returned above onError: (error, __: MutationVariables, context) => { queryClient.setQueryData(key, context!.previousMyPlaylists) onError?.(error as unknown as JsonApiErrorDocument) }, onSuccess: () => { onSuccess?.() }, // Always refetch after error or success: onSettled: (_, __, variables: MutationVariables) => { queryClient.invalidateQueries({ queryKey: playlistsKeys.lists(), refetchType: 'all', }) queryClient.invalidateQueries({ queryKey: playlistsKeys.detail(variables.playlistId), refetchType: 'all', }) }, }) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/features/playlists/edit-playlist/ui/edit-playlist-form.tsx ================================================ import { useEffect } from 'react' import { useForm } from 'react-hook-form' import type { SchemaUpdatePlaylistRequestPayload } from '../../../../shared/api/schema.ts' import { queryErrorHandlerForRHFFactory } from '../../../../shared/ui/util/query-error-handler-for-rhf-factory.ts' import { usePlaylistQuery } from '../api/use-playlist-query.tsx' import { useUpdatePlaylistMutation } from '../api/use-update-playlist-mutation.ts' type Props = { playlistId: string | null onCancelEditing: () => void } export const EditPlaylistForm = ({ playlistId, onCancelEditing }: Props) => { const { register, handleSubmit, reset, setError, formState: { errors }, } = useForm() useEffect(() => { reset() }, [playlistId]) const { data, isPending, isError } = usePlaylistQuery(playlistId) const { mutate } = useUpdatePlaylistMutation({ onSuccess: () => { onCancelEditing() }, onError: queryErrorHandlerForRHFFactory({ setError }), }) const onSubmit = (data: SchemaUpdatePlaylistRequestPayload) => { mutate({ ...data, playlistId: playlistId! }) } const handleCancelEditingClick = () => { onCancelEditing() } if (!playlistId) return <> if (isPending) return

    Loading...

    if (isError) return

    Error...

    return (

    Edit Playlist

    {errors.title &&

    {errors.title.message}

    }

    {errors.description &&

    {errors.description.message}

    } {errors.root?.server &&

    {errors.root?.server.message}

    }
    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/pages/auth/oauth-callback-page.tsx ================================================ import { useEffect } from 'react' export function OAuthCallbackPage() { useEffect(() => { const url = new URL(window.location.href) const code = url.searchParams.get('code') if (code && window.opener) { window.opener.postMessage({ code }, window.location.origin) } window.close() }, []) return ( <>

    OAuth2 Callback page

    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/pages/my-playlists-page.tsx ================================================ import { Navigate } from '@tanstack/react-router' import { useState } from 'react' import { useMeQuery } from '../features/auth/api/use-me-query.ts' import { AddPlaylistForm } from '../features/playlists/add-playlist/ui/add-playlist-form.tsx' import { EditPlaylistForm } from '../features/playlists/edit-playlist/ui/edit-playlist-form.tsx' import { Playlists } from '../widgets/playlists/ui/playlists.tsx' export function MyPlaylistsPage() { const { data, isPending } = useMeQuery() const [editingPlaylistId, setEditingPlaylistId] = useState(null) const handlePlaylistDelete = (playlistId: string) => { if (playlistId === editingPlaylistId) { setEditingPlaylistId(null) } } if (isPending) return
    Loading...
    if (!data) { return } return (

    My Playlists



    setEditingPlaylistId(playlistId)} onPlaylistDeleted={handlePlaylistDelete} />
    setEditingPlaylistId(null)} />
    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/pages/playlists-page.tsx ================================================ import { Playlists } from '../widgets/playlists/ui/playlists.tsx' export function PlaylistsPage() { return (

    hello it-incubator!!!

    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/api/client.ts ================================================ import createClient, { type Middleware } from 'openapi-fetch' import { apiKey, baseUrl } from '../config/api-config.ts' import { localStorageKeys } from '../config/localstorage-keys.ts' // generated by openapi-typescript import type { paths } from './schema' // mutex let refreshPromise: Promise | null = null function makeRefreshToken() { if (!refreshPromise) { refreshPromise = (async (): Promise => { const refreshToken = localStorage.getItem('musicfun-refresh-token') if (!refreshToken) throw new Error('No refresh token') const response = await fetch(baseUrl + 'auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', 'API-KEY': apiKey, }, body: JSON.stringify({ refreshToken: refreshToken, }), }) if (!response.ok) { localStorage.removeItem(localStorageKeys.accessToken) localStorage.removeItem(localStorageKeys.refreshToken) throw new Error('Refresh token failed') } const data = await response.json() localStorage.setItem(localStorageKeys.accessToken, data.accessToken) localStorage.setItem(localStorageKeys.refreshToken, data.refreshToken) })() refreshPromise.finally(() => { refreshPromise = null }) return refreshPromise } } const authMiddleware: Middleware = { onRequest({ request }) { // set "foo" header const accessToken = localStorage.getItem(localStorageKeys.accessToken) if (accessToken) { request.headers.set('Authorization', 'Bearer ' + accessToken) } // @ts-expect-error hot fix request._retryRequest = request.clone() return request }, async onResponse({ request, response }) { if (response.ok) return response if (!response.ok && response.status !== 401) { const errorBody = await response.json() throw errorBody } try { await makeRefreshToken() // @ts-expect-error ignore it const originalRequest: Request = request._retryRequest const retryRequest = new Request(originalRequest, { headers: new Headers(originalRequest.headers), }) retryRequest.headers.set( 'Authorization', 'Bearer ' + localStorage.getItem(localStorageKeys.accessToken) ) return fetch(retryRequest) } catch { return response } }, } export const client = createClient({ baseUrl: baseUrl, headers: { 'api-key': apiKey, }, }) client.use(authMiddleware) ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/api/keys-factories/auth-keys-factory.ts ================================================ export const authKeys = { all: ['auth'], me: () => [...authKeys.all, 'me'], } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/api/keys-factories/playlists-keys-factory.ts ================================================ import type { SchemaGetPlaylistsRequestPayload } from '../schema.ts' export const playlistsKeys = { all: ['playlists'], lists: () => [...playlistsKeys.all, 'lists'], myList: () => [...playlistsKeys.lists(), 'my'], list: (filters: Partial) => [...playlistsKeys.lists(), filters], details: () => [...playlistsKeys.all, 'details'], detail: (id: string) => [...playlistsKeys.details(), id], } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/api/schema.ts ================================================ /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ export interface paths { '/playlists/my': { parameters: { query?: never header?: never path?: never cookie?: never } /** * Get my playlists * @deprecated */ get: operations['PlaylistsController_getMyPlaylists'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists': { parameters: { query?: never header?: never path?: never cookie?: never } /** * Retrieve all playlists * @description Query parameters must conform to the **GetPlaylistsRequestPayload** schema. */ get: operations['PlaylistsPublicController_getPlaylists'] put?: never /** Create a new playlist */ post: operations['PlaylistsController_createPlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get a single playlist by ID */ get: operations['PlaylistsPublicController_getPlaylistById'] /** Update a playlist */ put: operations['PlaylistsController_updatePlaylist'] post?: never /** Delete a playlist */ delete: operations['PlaylistsController_deletePlaylist'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/reorder': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never /** Reorder playlists */ put: operations['PlaylistsController_reorderPlaylist'] post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/images/main': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** * Upload playlist cover * @description Minimum height — 500px; image must be square */ post: operations['PlaylistsController_uploadMainImage'] /** Delete playlist cover */ delete: operations['PlaylistsController_deleteTrackCover'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get list of all tracks in all playlists */ get: operations['TracksPublicController_getAllTracks'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get list of tracks in a playlist */ get: operations['TracksPublicController_getPlaylistTracks'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}': { parameters: { query?: never header?: never path?: never cookie?: never } /** Get track details by ID */ get: operations['TracksPublicController_getTrackDetails'] /** Update track information */ put: operations['TracksController_updateTrack'] post?: never /** Permanently delete a track */ delete: operations['TracksController_deleteTrackCompletely'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/likes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Like or toggle like on a track */ post: operations['TracksPublicController_likeTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/dislikes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Dislike or toggle dislike on a track */ post: operations['TracksPublicController_dislikeTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/reactions': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove user reaction from a track */ delete: operations['TracksPublicController_removeTrackReaction'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/likes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Like a playlist */ post: operations['PlaylistsPublicController_likePlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/dislikes': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Dislike a playlist */ post: operations['PlaylistsPublicController_dislikePlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/reactions': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove user reaction from a playlist */ delete: operations['PlaylistsPublicController_removePlaylistReaction'] options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/tracks/{trackId}/reorder': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never /** Reorder tracks in a playlist */ put: operations['TracksController_reorderTrack'] post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/relationships/tracks': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Add a track to your playlist */ post: operations['TracksController_addTrackToPlaylist'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/{playlistId}/relationships/tracks/{trackId}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Remove a track from your playlist */ delete: operations['TracksController_unbindTrackFromPlaylist'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/actions/publish': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Publish a track (make it publicly available) */ post: operations['TracksController_publishTrack'] delete?: never options?: never head?: never patch?: never trace?: never } '/playlists/tracks/{trackId}/cover': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Upload track cover */ post: operations['TracksController_uploadTrackCover'] /** Delete track cover */ delete: operations['TracksController_deleteTrackCover'] options?: never head?: never patch?: never trace?: never } '/playlists/tracks/upload': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a track with MP3 file upload */ post: operations['TracksController_uploadTrackMp3'] delete?: never options?: never head?: never patch?: never trace?: never } '/artists': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a new artist */ post: operations['ArtistsController_createArtist'] delete?: never options?: never head?: never patch?: never trace?: never } '/artists/search': { parameters: { query?: never header?: never path?: never cookie?: never } /** Search artists by substring */ get: operations['ArtistsController_searchArtist'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/artists/{id}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Delete an artist by ID */ delete: operations['ArtistsController_deleteArtist'] options?: never head?: never patch?: never trace?: never } '/auth/oauth-redirect': { parameters: { query?: never header?: never path?: never cookie?: never } /** * OAuth редирект * @description The callback URL to redirect after grand access, * https://oauth.apihub.it-incubator.io/realms/apihub/protocol/openid-connect/auth?client_id=spotifun&response_type=code&redirect_uri=http://localhost:3000/oauth2/callback&scope=openid */ get: operations['AuthController_OauthRedirect'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/auth/login': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Залогиниться с помощью кода, полученного после редиректа после авторизации через OAuth */ post: operations['AuthController_login'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/refresh': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Обновить пару refresh/access токенов */ post: operations['AuthController_refresh'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/logout': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Деактивировать refresh-token */ post: operations['AuthController_logout'] delete?: never options?: never head?: never patch?: never trace?: never } '/auth/me': { parameters: { query?: never header?: never path?: never cookie?: never } /** Получить текущего пользователя по access токену */ get: operations['AuthController_getMe'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/tags': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never /** Create a new tag */ post: operations['TagsController_createTag'] delete?: never options?: never head?: never patch?: never trace?: never } '/tags/search': { parameters: { query?: never header?: never path?: never cookie?: never } /** Search tags by substring */ get: operations['TagsController_searchTags'] put?: never post?: never delete?: never options?: never head?: never patch?: never trace?: never } '/tags/{id}': { parameters: { query?: never header?: never path?: never cookie?: never } get?: never put?: never post?: never /** Delete a tag by ID */ delete: operations['TagsController_deleteTag'] options?: never head?: never patch?: never trace?: never } } export type webhooks = Record export interface components { schemas: { UserOutputDTO: { /** @description Unique identifier of the user */ id: string /** @description Name of the user */ name: string } /** * @description Type of the image size (e.g., original, thumbnail variants) * @enum {string} */ ImageSizeType: 'original' | 'thumbnail' | 'medium' ImageDto: { /** @description Type of the image size (e.g., original, thumbnail variants) */ type: components['schemas']['ImageSizeType'] /** @description Image width in pixels */ width: number /** @description Image height in pixels */ height: number /** @description Image file size in bytes */ fileSize: number /** @description Full public URL of the image */ url: string } PlaylistImagesOutputDTO: { /** @description Original images and thumbnail previews */ main?: components['schemas']['ImageDto'][] } GetTagOutput: { /** @description Unique identifier of the tag */ id: string /** @description Original name of the tag */ name: string } /** * @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike * @enum {number} */ ReactionValue: 0 | 1 | -1 PlaylistAttributesDto: { /** @description Title of the playlist */ title: string /** @description Description of the playlist */ description: string | null /** * Format: date-time * @description Date and time when the playlist was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the playlist was last updated (ISO 8601) */ updatedAt: string /** @description Order index of the playlist */ order: number /** @description User who created the playlist */ user: components['schemas']['UserOutputDTO'] /** @description Images associated with the playlist */ images: components['schemas']['PlaylistImagesOutputDTO'] /** @description Tags linked to the playlist */ tags: components['schemas']['GetTagOutput'][] /** @description Total number of likes for this playlist */ likesCount: number /** @description Total number of dislikes for this playlist */ dislikesCount: number /** @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike */ currentUserReaction: components['schemas']['ReactionValue'] } PlaylistListItemJsonApiData: { /** @description Unique identifier of the playlist */ id: string /** * @description Resource type (should be "playlists") * @example playlists */ type: string /** @description Attributes of the playlist resource */ attributes: components['schemas']['PlaylistAttributesDto'] } GetMyPlaylistsOutput: { /** @description Array of playlist resource objects owned by the current user */ data: components['schemas']['PlaylistListItemJsonApiData'][] } CreatePlaylistRequestPayload: { /** @description Playlist title (1 to 100 characters) */ title: string /** @description Playlist description (up to 1000 characters) */ description: string | null } PlaylistOutputAttributes: { /** @description Title of the playlist */ title: string /** @description Description of the playlist */ description: string | null /** * Format: date-time * @description Date and time when the playlist was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the playlist was last updated (ISO 8601) */ updatedAt: string /** @description Order index of the playlist */ order: number /** @description User who created the playlist */ user: components['schemas']['UserOutputDTO'] /** @description Images associated with the playlist */ images: components['schemas']['PlaylistImagesOutputDTO'] /** @description Tags linked to the playlist */ tags: components['schemas']['GetTagOutput'][] /** @description Total number of likes for this playlist */ likesCount: number /** @description Total number of dislikes for this playlist */ dislikesCount: number /** @description User reaction: 0 – guest or no reaction; 1 – like; -1 – dislike */ currentUserReaction: components['schemas']['ReactionValue'] } PlaylistOutput: { /** @description Unique identifier of the playlist */ id: string /** * @description Resource type (should be "playlists") * @example playlists */ type: string /** @description Playlist attributes object */ attributes: components['schemas']['PlaylistOutputAttributes'] } GetPlaylistOutput: { /** @description JSON:API single-resource response wrapper */ data: components['schemas']['PlaylistOutput'] } UpdatePlaylistRequestPayload: { /** @description Playlist title (1 – 100 characters) */ title: string /** * @description Playlist description (up to 1000 characters) * @example Cool playlist */ description: string | null /** @description Tag IDs to associate with the playlist (0 – 5 items; [] = clear tags) */ tagIds: string[] } ReorderPlaylistsRequestPayload: { /** * Format: uuid * @description ID of the playlist after which the current playlist should be inserted. Send null to place the playlist at the beginning of the list. */ putAfterItemId: string | null } GetImagesOutput: { /** @description List of original images and thumbnail versions (e.g., original, 320x180, etc.) */ main?: components['schemas']['ImageDto'][] } GetTracksRequestPayload: { /** * @description Page number for pagination (starting from 1) * @default 1 */ pageNumber: number /** * @description Page size for pagination (between 1 and 20) * @default 20 */ pageSize: number /** @description Search term for filtering playlists by name */ search?: string /** * @description Field by which to sort tracks * @default publishedAt * @enum {string} */ sortBy: 'publishedAt' | 'likesCount' /** * @description Sort direction (ascending or descending) * @default desc * @enum {string} */ sortDirection: 'asc' | 'desc' /** @description Filter by tag IDs (multiple values allowed) */ tagsIds?: string[] /** @description Filter by artist IDs (multiple values allowed) */ artistsIds?: string[] /** @description Filter by user ID (track creator's ID) */ userId?: string /** @description If true, include unpublished tracks (drafts) of current user if userId === currentUserId */ includeDrafts?: boolean /** * @description Pagination type: "offset" for page-number pagination; "cursor" for keyset/seek-based pagination. * @default offset * @enum {string} */ paginationType: 'offset' | 'cursor' /** @description Base64-encoded cursor for keyset pagination. Used only if paginationType is "cursor". */ cursor?: string | null } AttachmentDto: { /** @description Unique identifier of the entity */ id: string /** * Format: date-time * @description Date and time when the entity was added */ addedAt: string /** * Format: date-time * @description Date and time when the entity was last updated */ updatedAt: string /** @description Version number of the entity (for concurrency control) */ version: number /** * @description Public URL to access the uploaded file * @example https://cdn.example.com/uploads/track123/cover.jpg */ url: string /** * @description MIME type of the file * @example image/jpeg */ contentType: string /** * @description Original filename uploaded by the user * @example cover.jpg */ originalName: string /** * @description Size of the file in bytes * @example 34872 */ fileSize: number } TrackListItemOutputAttributes: { title: string addedAt: string attachments: components['schemas']['AttachmentDto'][] images: components['schemas']['GetImagesOutput'] user: components['schemas']['UserOutputDTO'] /** * @description 0 – не залогинен или не реагировал; 1 – лайк; −1 – дизлайк * @enum {number} */ currentUserReaction: 0 | 1 | -1 isPublished: boolean publishedAt?: string } ArtistRelationship: { id: string type: string } ArtistsRelationship: { data: components['schemas']['ArtistRelationship'][] } TrackRelationships: { artists: components['schemas']['ArtistsRelationship'] } TrackListItemOutput: { id: string /** @example tracks */ type: string attributes: components['schemas']['TrackListItemOutputAttributes'] relationships: components['schemas']['TrackRelationships'] } JsonApiMetaWithPagingAndCursor: { page: number pageSize: number /** @description Total count may be absent when using keyset pagination */ totalCount: number | null /** @description Total number of pages */ pagesCount: number | null /** @description Cursor for the next page */ nextCursor: string | null } OmitTypeClass: { /** @description Name of the artist */ name: string } IncludedArtistOutput: { id: string type: string attributes: components['schemas']['OmitTypeClass'] } GetTrackListOutput: { data: components['schemas']['TrackListItemOutput'][] meta: components['schemas']['JsonApiMetaWithPagingAndCursor'] included: components['schemas']['IncludedArtistOutput'][] } PlaylistTrackAttributes: { /** @description Title of the track */ title: string /** @description Order index of the track in the playlist */ order: number /** * Format: date-time * @description Date and time when the track was added to the playlist (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the track was last updated in the playlist (ISO 8601) */ updatedAt: string /** @description Attachments related to the track */ attachments: components['schemas']['AttachmentDto'][] /** @description Images associated with the track */ images: components['schemas']['GetImagesOutput'] /** * @description User reaction: 0 – guest or no reaction; 1 – liked; -1 – disliked * @enum {number|null} */ currentUserReaction: 0 | 1 | -1 | null } GetPlaylistTrackListOutputData: { id: string /** @example tracks */ type: string attributes: components['schemas']['PlaylistTrackAttributes'] relationships: components['schemas']['TrackRelationships'] } JsonApiMeta: { totalCount: number } GetPlaylistTrackListOutput: { data: components['schemas']['GetPlaylistTrackListOutputData'][] meta: components['schemas']['JsonApiMeta'] included: components['schemas']['IncludedArtistOutput'][] } GetArtistOutput: { /** @description Unique identifier of the artist */ id: string /** @description Name of the artist */ name: string } TrackDetailsAttributes: { /** @description Track title */ title: string /** @description Track lyrics text */ lyrics?: string | null /** * Format: date-time * @description Release date in ISO 8601 format */ releaseDate?: string | null /** * Format: date-time * @description Date and time when the track was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the track was last updated (ISO 8601) */ updatedAt: string /** @description Duration of the track in seconds */ duration: number /** @description Total number of likes for this track */ likesCount: number /** @description Total number of dislikes for this track */ dislikesCount: number /** @description List of attachments related to the track */ attachments: components['schemas']['AttachmentDto'][] /** @description Images associated with the track */ images: components['schemas']['GetImagesOutput'] /** @description Tags associated with the track */ tags: components['schemas']['GetTagOutput'][] /** @description Artists associated with the track */ artists: components['schemas']['GetArtistOutput'][] /** @description Publication status of the track */ isPublished: boolean /** * Format: date-time * @description Publication date in ISO 8601 format */ publishedAt?: string | null /** * @description User reaction: 0 – guest or no reaction; 1 – user liked; -1 – user disliked * @enum {number} */ currentUserReaction: 0 | 1 | -1 } TrackDetailsData: { /** @description Unique identifier of the track */ id: string /** * @description Resource type (should be "tracks") * @example tracks */ type: string /** @description Detailed attributes of the track resource */ attributes: components['schemas']['TrackDetailsAttributes'] } GetTrackDetailsOutput: { /** @description JSON:API single-track details response wrapper */ data: components['schemas']['TrackDetailsData'] } ReactionOutput: { objectId: string /** @enum {number} */ value: 0 | 1 | -1 likes: number dislikes: number } GetPlaylistsRequestPayload: { /** * @description Page number for pagination (starting from 1) * @default 1 */ pageNumber: number /** * @description Page size for pagination (between 1 and 20) * @default 20 */ pageSize: number /** @description Search term for filtering playlists by name */ search?: string /** * @description Field by which to sort playlists * @default addedAt * @enum {string} */ sortBy: 'addedAt' | 'likesCount' /** * @description Sort direction (ascending or descending) * @default desc * @enum {string} */ sortDirection: 'asc' | 'desc' /** @description Filter by tag IDs. Multiple values allowed, e.g.: tagsIds=tag1&tagsIds=tag2 */ tagsIds?: string[] /** @description Filter by user ID (playlist creator’s ID) */ userId?: string /** @description Filter by track ID – only playlists containing this track will be returned */ trackId?: string } JsonApiMetaWithPaging: { totalCount: number page: number pageSize: number pagesCount: number } GetPlaylistsOutput: { /** @description Array of playlist resource objects */ data: components['schemas']['PlaylistListItemJsonApiData'][] /** @description Pagination metadata for the playlists list */ meta: components['schemas']['JsonApiMetaWithPaging'] } ReorderTracksRequestPayload: { /** * Format: uuid * @description ID of the track after which the current track should be inserted. Send null to place the track at the beginning of the list. * @example a1b2c3d4-e5f6-7890-abcd-1234567890ef */ putAfterItemId: string | null } UpdateTrackRequestPayload: { /** @description Track title (1 to 100 characters) */ title: string /** @description Track lyrics (up to 5000 characters) */ lyrics: string | null /** * Format: date-time * @description Release date in ISO 8601 format */ releaseDate: string | null /** @description Array of tag IDs to associate with the track (up to 5) */ tagIds: string[] /** @description Array of artist IDs to associate with the track (up to 5) */ artistsIds: string[] } TrackOutputAttributes: { /** @description Track title */ title: string /** @description Track lyrics text */ lyrics?: string | null /** * Format: date-time * @description Release date in ISO 8601 format */ releaseDate?: string | null /** * Format: date-time * @description Date and time when the track was added (ISO 8601) */ addedAt: string /** * Format: date-time * @description Date and time when the track was last updated (ISO 8601) */ updatedAt: string /** @description Duration of the track in seconds */ duration: number /** @description Total number of likes for this track */ likesCount: number /** @description Total number of dislikes for this track */ dislikesCount: number /** @description List of attachments related to the track */ attachments: components['schemas']['AttachmentDto'][] /** @description Images associated with the track */ images: components['schemas']['GetImagesOutput'] /** @description Tags associated with the track */ tags: components['schemas']['GetTagOutput'][] /** @description Artists associated with the track */ artists: components['schemas']['GetArtistOutput'][] /** @description Publication status of the track */ isPublished: boolean /** * Format: date-time * @description Publication date in ISO 8601 format */ publishedAt?: string | null /** * @description User reaction: 0 – guest or no reaction; 1 – user liked; -1 – user disliked * @enum {number} */ currentUserReaction: 0 | 1 | -1 } TrackOutput: { /** @description Unique identifier of the track */ id: string /** * @description Resource type (should be "tracks") * @example tracks */ type: string /** @description Attributes of the track resource */ attributes: components['schemas']['TrackOutputAttributes'] } GetTrackOutput: { /** @description JSON:API single-track response wrapper */ data: components['schemas']['TrackOutput'] } AddTrackToPlaylistRequestPayload: { /** @description ID of the track to add to the playlist */ trackId: string } CreateArtistRequestPayload: { /** @description Artist name (must be between 2 and 30 characters) */ name: string } LoginRequestPayload: { /** @description Код, полученный от oauth-сервер после редиректа */ code: string /** * @description Укажите тоже значение, что и во время первого запроса на oauth-сервер * @example http://localhost:3000/oauth2/callback */ redirectUri: string /** * @description Срок жизни accessToken-а (по дефолту "3m"), Можно использовать значение в формате: be a string like "60s", "3m", "2h", "1d" * @example 3m */ accessTokenTTL?: string /** @description Как долго будет жить refreshToken. Если true - 1 месяц, если false - 30 минут. Явно указанный accessTokenTTL не должен быть больше, чем время жизни refreshToken */ rememberMe: boolean } RefreshOutput: { refreshToken: string accessToken: string } BadRequestException: Record UnauthorizedException: Record RefreshRequestPayload: { refreshToken: string } LogoutRequestPayload: { refreshToken: string } GetMeOutput: { userId: string login: string } CreateTagRequestPayload: { /** @description Tag name (2 to 30 characters) */ name: string } /** * Format: binary * @description Файл в multipart/form-data */ BinaryFile: string } responses: never parameters: never requestBodies: never headers: never pathItems: never } export type SchemaUserOutputDto = components['schemas']['UserOutputDTO'] export type SchemaImageSizeType = components['schemas']['ImageSizeType'] export type SchemaImageDto = components['schemas']['ImageDto'] export type SchemaPlaylistImagesOutputDto = components['schemas']['PlaylistImagesOutputDTO'] export type SchemaGetTagOutput = components['schemas']['GetTagOutput'] export type SchemaReactionValue = components['schemas']['ReactionValue'] export type SchemaPlaylistAttributesDto = components['schemas']['PlaylistAttributesDto'] export type SchemaPlaylistListItemJsonApiData = components['schemas']['PlaylistListItemJsonApiData'] export type SchemaGetMyPlaylistsOutput = components['schemas']['GetMyPlaylistsOutput'] export type SchemaCreatePlaylistRequestPayload = components['schemas']['CreatePlaylistRequestPayload'] export type SchemaPlaylistOutputAttributes = components['schemas']['PlaylistOutputAttributes'] export type SchemaPlaylistOutput = components['schemas']['PlaylistOutput'] export type SchemaGetPlaylistOutput = components['schemas']['GetPlaylistOutput'] export type SchemaUpdatePlaylistRequestPayload = components['schemas']['UpdatePlaylistRequestPayload'] export type SchemaReorderPlaylistsRequestPayload = components['schemas']['ReorderPlaylistsRequestPayload'] export type SchemaGetImagesOutput = components['schemas']['GetImagesOutput'] export type SchemaGetTracksRequestPayload = components['schemas']['GetTracksRequestPayload'] export type SchemaAttachmentDto = components['schemas']['AttachmentDto'] export type SchemaTrackListItemOutputAttributes = components['schemas']['TrackListItemOutputAttributes'] export type SchemaArtistRelationship = components['schemas']['ArtistRelationship'] export type SchemaArtistsRelationship = components['schemas']['ArtistsRelationship'] export type SchemaTrackRelationships = components['schemas']['TrackRelationships'] export type SchemaTrackListItemOutput = components['schemas']['TrackListItemOutput'] export type SchemaJsonApiMetaWithPagingAndCursor = components['schemas']['JsonApiMetaWithPagingAndCursor'] export type SchemaOmitTypeClass = components['schemas']['OmitTypeClass'] export type SchemaIncludedArtistOutput = components['schemas']['IncludedArtistOutput'] export type SchemaGetTrackListOutput = components['schemas']['GetTrackListOutput'] export type SchemaPlaylistTrackAttributes = components['schemas']['PlaylistTrackAttributes'] export type SchemaGetPlaylistTrackListOutputData = components['schemas']['GetPlaylistTrackListOutputData'] export type SchemaJsonApiMeta = components['schemas']['JsonApiMeta'] export type SchemaGetPlaylistTrackListOutput = components['schemas']['GetPlaylistTrackListOutput'] export type SchemaGetArtistOutput = components['schemas']['GetArtistOutput'] export type SchemaTrackDetailsAttributes = components['schemas']['TrackDetailsAttributes'] export type SchemaTrackDetailsData = components['schemas']['TrackDetailsData'] export type SchemaGetTrackDetailsOutput = components['schemas']['GetTrackDetailsOutput'] export type SchemaReactionOutput = components['schemas']['ReactionOutput'] export type SchemaGetPlaylistsRequestPayload = components['schemas']['GetPlaylistsRequestPayload'] export type SchemaJsonApiMetaWithPaging = components['schemas']['JsonApiMetaWithPaging'] export type SchemaGetPlaylistsOutput = components['schemas']['GetPlaylistsOutput'] export type SchemaReorderTracksRequestPayload = components['schemas']['ReorderTracksRequestPayload'] export type SchemaUpdateTrackRequestPayload = components['schemas']['UpdateTrackRequestPayload'] export type SchemaTrackOutputAttributes = components['schemas']['TrackOutputAttributes'] export type SchemaTrackOutput = components['schemas']['TrackOutput'] export type SchemaGetTrackOutput = components['schemas']['GetTrackOutput'] export type SchemaAddTrackToPlaylistRequestPayload = components['schemas']['AddTrackToPlaylistRequestPayload'] export type SchemaCreateArtistRequestPayload = components['schemas']['CreateArtistRequestPayload'] export type SchemaLoginRequestPayload = components['schemas']['LoginRequestPayload'] export type SchemaRefreshOutput = components['schemas']['RefreshOutput'] export type SchemaBadRequestException = components['schemas']['BadRequestException'] export type SchemaUnauthorizedException = components['schemas']['UnauthorizedException'] export type SchemaRefreshRequestPayload = components['schemas']['RefreshRequestPayload'] export type SchemaLogoutRequestPayload = components['schemas']['LogoutRequestPayload'] export type SchemaGetMeOutput = components['schemas']['GetMeOutput'] export type SchemaCreateTagRequestPayload = components['schemas']['CreateTagRequestPayload'] export type SchemaBinaryFile = components['schemas']['BinaryFile'] export type $defs = Record export interface operations { PlaylistsController_getMyPlaylists: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of playlists retrieved successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetMyPlaylistsOutput'] } } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_getPlaylists: { parameters: { query?: { /** @description Page number for pagination (starting from 1) */ pageNumber?: number /** @description Page size for pagination (between 1 and 20) */ pageSize?: number /** @description Search term for filtering playlists by name */ search?: string /** @description Field by which to sort playlists */ sortBy?: 'addedAt' | 'likesCount' /** @description Sort direction (ascending or descending) */ sortDirection?: 'asc' | 'desc' /** @description Filter by tag IDs. Multiple values allowed, e.g.: tagsIds=tag1&tagsIds=tag2 */ tagsIds?: string[] /** @description Filter by user ID (playlist creator’s ID) */ userId?: string /** @description Filter by track ID – only playlists containing this track will be returned */ trackId?: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: JSON:API list of playlists with pagination */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistsOutput'] } } } } PlaylistsController_createPlaylist: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreatePlaylistRequestPayload'] } } responses: { /** @description Created: Playlist created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistOutput'] } } /** @description Forbidden: Playlist creation limit exceeded */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_getPlaylistById: { parameters: { query?: never header?: never path: { /** @description ID of the playlist */ playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Playlist retrieved successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistOutput'] } } /** @description Not Found: Playlist with the given ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_updatePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['UpdatePlaylistRequestPayload'] } } responses: { /** @description No Content: Playlist updated successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Bad Request: Validation error (e.g., tag limit exceeded) */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: You do not have permission to update this playlist */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_deletePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Playlist deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Insufficient permissions to delete this playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_reorderPlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['ReorderPlaylistsRequestPayload'] } } responses: { /** @description No Content: Playlist order updated successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist or putAfterItemId not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_uploadMainImage: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'multipart/form-data': { /** @description Maximum size 1 MB; minimum height 500px; image must be square */ file: components['schemas']['BinaryFile'] } } } responses: { /** @description OK: Cover uploaded successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetImagesOutput'] } } /** @description Bad Request: Invalid image format or dimensions */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No permission to upload cover for this playlist */ 403: { headers: { [name: string]: unknown } content?: never } } } PlaylistsController_deleteTrackCover: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Cover deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Removing another user’s playlist cover is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_getAllTracks: { parameters: { query?: { /** @description Page number for pagination (starting from 1) */ pageNumber?: number /** @description Page size for pagination (between 1 and 20) */ pageSize?: number /** @description Search term for filtering playlists by name */ search?: string /** @description Field by which to sort tracks */ sortBy?: 'publishedAt' | 'likesCount' /** @description Sort direction (ascending or descending) */ sortDirection?: 'asc' | 'desc' /** @description Filter by tag IDs (multiple values allowed) */ tagsIds?: string[] /** @description Filter by artist IDs (multiple values allowed) */ artistsIds?: string[] /** @description Filter by user ID (track creator's ID) */ userId?: string /** @description If true, include unpublished tracks (drafts) of current user if userId === currentUserId */ includeDrafts?: boolean /** @description Pagination type: "offset" for page-number pagination; "cursor" for keyset/seek-based pagination. */ paginationType?: 'offset' | 'cursor' /** @description Base64-encoded cursor for keyset pagination. Used only if paginationType is "cursor". */ cursor?: string | null } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Paginated list of tracks */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackListOutput'] } } } } TracksPublicController_getPlaylistTracks: { parameters: { query?: never header?: never path: { /** @description ID of the playlist to retrieve tracks for */ playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: List of tracks in the playlist */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetPlaylistTrackListOutput'] } } /** @description Not Found: Playlist with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_getTrackDetails: { parameters: { query?: never header?: never path: { /** @description ID of the track to retrieve details for */ trackId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Track details with attachments */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackDetailsOutput'] } } /** @description Not Found: Track with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_updateTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['UpdateTrackRequestPayload'] } } responses: { /** @description OK: Track updated successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackOutput'] } } /** @description Bad Request: Tag or artist limit exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Editing another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track or playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_deleteTrackCompletely: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track permanently deleted */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Deleting another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_likeTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description Created: User reaction recorded and counters updated */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid track ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_dislikeTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description Created: User reaction recorded and counters updated */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid track ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksPublicController_removeTrackReaction: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Reaction removed successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_likePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description Created: Like recorded successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid playlist ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_dislikePlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description Created: Dislike recorded successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Bad Request: Invalid playlist ID */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } PlaylistsPublicController_removePlaylistReaction: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody?: never responses: { /** @description OK: Reaction removed successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['ReactionOutput'] } } /** @description Unauthorized */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_reorderTrack: { parameters: { query?: never header?: never path: { playlistId: string trackId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['ReorderTracksRequestPayload'] } } responses: { /** @description OK: Track order updated successfully */ 200: { headers: { [name: string]: unknown } content?: never } /** @description Bad Request: Cannot place a track after itself */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track or putAfterItemId not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_addTrackToPlaylist: { parameters: { query?: never header?: never path: { playlistId: string } cookie?: never } requestBody: { content: { 'application/json': components['schemas']['AddTrackToPlaylistRequestPayload'] } } responses: { /** @description No Content: Track added to the playlist successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist or track limit exceeded (max 10 tracks) */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_unbindTrackFromPlaylist: { parameters: { query?: never header?: never path: { playlistId: string trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track removed from the playlist */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: No access to the playlist */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Playlist not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_publishTrack: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Track published successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Publishing another user’s track is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Track is already published */ 409: { headers: { [name: string]: unknown } content?: never } } } TracksController_uploadTrackCover: { parameters: { query?: never header?: never path: { /** @description ID of the track for which the cover is being uploaded */ trackId: string } cookie?: never } /** @description Image file:
    * • Field name — cover
    * • Allowed MIME types — image/jpeg, image/png, image/gif
    * • Maximum size — 100 KB */ requestBody: { content: { 'multipart/form-data': { /** Format: binary */ cover: string } } } responses: { /** @description OK: Cover uploaded successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetImagesOutput'] } } /** @description Bad Request: Invalid file or size exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Cannot upload a cover for another user’s track */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_deleteTrackCover: { parameters: { query?: never header?: never path: { trackId: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Cover deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Removing another user's track cover is not allowed */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Track not found */ 404: { headers: { [name: string]: unknown } content?: never } } } TracksController_uploadTrackMp3: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'multipart/form-data': { /** @example My cool track */ title: string /** Format: binary */ file: string } } } responses: { /** @description OK: Track created successfully */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTrackOutput'] } } /** @description Bad Request: Invalid file format or file size exceeded */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Internal Server Error: Error saving file or track */ 500: { headers: { [name: string]: unknown } content?: never } } } ArtistsController_createArtist: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreateArtistRequestPayload'] } } responses: { /** @description Created: Artist created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetArtistOutput'] } } /** @description Bad Request: Validation error or invalid input */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Limit of 100 artists per user reached */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Artist with the given name already exists */ 409: { headers: { [name: string]: unknown } content?: never } } } ArtistsController_searchArtist: { parameters: { query: { search: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of artists matching the search */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetArtistOutput'][] } } } } ArtistsController_deleteArtist: { parameters: { query?: never header?: never path: { id: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Artist deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Artist is attached to tracks or was created by another user */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Artist with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } AuthController_OauthRedirect: { parameters: { query?: { /** @description The callback URL to redirect after grand access, * https://oauth.apihub.it-incubator.io/realms/apihub/protocol/openid-connect/auth?client_id=spotifun&response_type=code&redirect_uri=http://localhost:3000/oauth2/callback&scope=openid */ callbackUrl?: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Редирект выполнен */ 200: { headers: { [name: string]: unknown } content?: never } } } AuthController_login: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['LoginRequestPayload'] } } responses: { /** @description OK: Успешно получена пара токенов */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['RefreshOutput'] } } /** @description BadRequest: Неверный формат запроса или отсутствуют обязательные параметры */ 400: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['BadRequestException'] } } /** @description Unauthorized: Код недействителен, истёк или не передан, или не совпадает redirectUri */ 401: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['UnauthorizedException'] } } } } AuthController_refresh: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['RefreshRequestPayload'] } } responses: { /** @description OK: Успешное обновление пары токенов */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['RefreshOutput'] } } /** @description Unauthorized: Refresh-token недействителен, истёк или не передан */ 401: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['UnauthorizedException'] } } } } AuthController_logout: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['LogoutRequestPayload'] } } responses: { /** @description OK: refresh токен деактивирован, при этом access-токен остаётся ещё валидным. */ 204: { headers: { [name: string]: unknown } content?: never } } } AuthController_getMe: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: Успешное получение информации о пользователе */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetMeOutput'] } } /** @description Unauthorized: access токен отсутствует или недействителен */ 401: { headers: { [name: string]: unknown } content?: never } } } TagsController_createTag: { parameters: { query?: never header?: never path?: never cookie?: never } requestBody: { content: { 'application/json': components['schemas']['CreateTagRequestPayload'] } } responses: { /** @description Created: Tag created successfully */ 201: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTagOutput'] } } /** @description Bad Request: Validation error */ 400: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Limit of 100 tags per user reached */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Conflict: Tag with the given name already exists */ 409: { headers: { [name: string]: unknown } content?: never } } } TagsController_searchTags: { parameters: { query: { /** @description Substring to search tags by (using normalized name) */ search: string } header?: never path?: never cookie?: never } requestBody?: never responses: { /** @description OK: List of matching tags */ 200: { headers: { [name: string]: unknown } content: { 'application/json': components['schemas']['GetTagOutput'][] } } /** @description Bad Request: Invalid search query */ 400: { headers: { [name: string]: unknown } content?: never } } } TagsController_deleteTag: { parameters: { query?: never header?: never path: { /** @description ID of the tag to delete */ id: string } cookie?: never } requestBody?: never responses: { /** @description No Content: Tag deleted successfully */ 204: { headers: { [name: string]: unknown } content?: never } /** @description Unauthorized: User not authenticated */ 401: { headers: { [name: string]: unknown } content?: never } /** @description Forbidden: Tag was created by another user or is attached to tracks or playlists */ 403: { headers: { [name: string]: unknown } content?: never } /** @description Not Found: Tag with the specified ID not found */ 404: { headers: { [name: string]: unknown } content?: never } } } } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/config/api-config.ts ================================================ export const baseUrl = import.meta.env.VITE_BASE_URL export const apiKey = import.meta.env.VITE_API_KEY ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/config/localstorage-keys.ts ================================================ export const localStorageKeys = { refreshToken: 'musicfun-refresh-token', accessToken: 'musicfun-access-token', } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/header/header.module.css ================================================ .header { border-bottom: #aaaaaa 1px solid; padding-bottom: 10px; } .container { max-width: 900px; margin: 0 auto; display: flex; flex-direction: row; justify-content: space-between; } .linksBlock { display: flex; gap: 10px; } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/header/header.tsx ================================================ import { Link } from '@tanstack/react-router' import type { ReactNode } from 'react' import styles from './header.module.css' type Props = { renderAccountBar: () => ReactNode } export const Header = ({ renderAccountBar }: Props) => (
    Playlists
    {renderAccountBar()}
    ) ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/pagination/pagination-nav/pagination-nav.module.css ================================================ .pagination { display: flex; gap: 8px; justify-content: center; } .pageButton { padding: 4px 10px; background: transparent; border: 1px solid #aaa; border-radius: 4px; cursor: pointer; font-weight: normal; transition: background 0.2s, color 0.2s; color: white; } .pageButtonActive { background: #ececec; font-weight: bold; cursor: default; color: black; } .ellipsis { padding: 4px 10px; user-select: none; } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/pagination/pagination-nav/pagination-nav.tsx ================================================ import { getPaginationPages } from '../utils/get-pagination-pages.ts' import s from './pagination-nav.module.css' type Props = { current: number pagesCount: number onChange: (page: number) => void isFetching: boolean } const SIBLING_COUNT = 1 export const PaginationNav = ({ current, pagesCount, onChange }: Props) => { const pages = getPaginationPages(current, pagesCount, SIBLING_COUNT) return (
    {pages.map((item, idx) => item === '...' ? ( ... ) : ( ) )}
    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/pagination/pagination.module.css ================================================ .container { display: flex; align-content: center; align-items: center; margin: 0 auto; gap: 40px; } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/pagination/pagination.tsx ================================================ import s from './Pagination.module.css' import { PaginationNav } from './pagination-nav/pagination-nav.tsx' type Props = { currentPage: number pagesCount: number onPageNumberChange: (page: number) => void isFetching: boolean } export const Pagination = ({ currentPage, pagesCount, onPageNumberChange, isFetching }: Props) => { return (
    {' '} {isFetching && '⌛️'}
    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/pagination/utils/get-pagination-pages.ts ================================================ /** * Генерирует массив страниц для отображения пагинации с учётом троеточий */ export const getPaginationPages = ( current: number, pagesCount: number, siblingCount: number ): (number | '...')[] => { if (pagesCount <= 1) return [] const pages: (number | '...')[] = [] // Границы диапазона вокруг текущей страницы const leftSibling = Math.max(2, current - siblingCount) const rightSibling = Math.min(pagesCount - 1, current + siblingCount) // Всегда показываем первую страницу pages.push(1) // Троеточие слева if (leftSibling > 2) { pages.push('...') } // Соседние страницы вокруг текущей for (let page = leftSibling; page <= rightSibling; page++) { pages.push(page) } // Троеточие справа if (rightSibling < pagesCount - 1) { pages.push('...') } // Всегда показываем последнюю страницу (если больше одной) if (pagesCount > 1) { pages.push(pagesCount) } return pages } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/ui/util/query-error-handler-for-rhf-factory.ts ================================================ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form' import { toast } from 'react-toastify' import { isJsonApiErrorDocument, type JsonApiErrorDocument, parseJsonApiErrors, } from '../../util/json-api-error.ts' export const queryErrorHandlerForRHFFactory = ({ setError, }: { setError?: UseFormSetError }) => { return (err: JsonApiErrorDocument) => { // 400 от сервера в JSON:API формате if (isJsonApiErrorDocument(err)) { const { fieldErrors, globalErrors } = parseJsonApiErrors(err) // полевые ошибки for (const [field, message] of Object.entries(fieldErrors)) { setError?.(field as Path, { type: 'server', message }) } // «глобальные» (без pointer) if (globalErrors.length > 0) { setError?.('root.server', { type: 'server', message: globalErrors.join('\n'), }) } } } } export const mutationGlobalErrorHandler = ( error: Error, _: unknown, __: unknown, mutation: unknown ) => { // @ts-expect-error look at MutationMeta type if (mutation.meta.globalErrorHandler === 'off') { return } if (isJsonApiErrorDocument(error)) { const { globalErrors } = parseJsonApiErrors(error) // «глобальные» (без pointer) if (globalErrors.length > 0) { toast(globalErrors.join('\n')) } } } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/shared/util/json-api-error.ts ================================================ export interface JsonApiError { status: string code?: string | number title?: string detail?: string source?: { pointer?: string; parameter?: string } meta?: Record } export interface JsonApiErrorDocument { errors: JsonApiError[] meta?: Record } export type ExtractError = T extends { error?: infer E } ? E : unknown /* --- типы ошибок, совпадающие с фильтром -------------------------------- */ export interface JsonApiError { status: string code?: string | number title?: string detail?: string source?: { pointer?: string; parameter?: string } meta?: Record } export interface JsonApiErrorDocument { errors: JsonApiError[] meta?: Record } export function isJsonApiErrorDocument(error: unknown): error is JsonApiErrorDocument { return ( typeof error === 'object' && error !== null && // @ts-expect-error type no matter Array.isArray(error.errors) ) } export function parseJsonApiErrors(errorDoc: JsonApiErrorDocument): { fieldErrors: Record globalErrors: string[] } { const fieldErrors: Record = {} const globalErrors: string[] = [] for (const err of errorDoc.errors) { const msg = err.detail ?? err.title ?? 'Unknown error' const ptr = err.source?.pointer if (ptr) { // убираем префикс JSON:API const field = ptr.replace(/^\/data\/attributes\//, '') fieldErrors[field] = msg } else { globalErrors.push(msg) } } return { fieldErrors, globalErrors } } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/vite-env.d.ts ================================================ /// ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/widgets/playlists/api/use-playlists-query.ts ================================================ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { client } from '../../../shared/api/client.ts' import { playlistsKeys } from '../../../shared/api/keys-factories/playlists-keys-factory.ts' import type { SchemaGetPlaylistsRequestPayload } from '../../../shared/api/schema.ts' export const usePlaylistsQuery = ( userId: string | undefined, filters: Partial ) => { const key = userId ? playlistsKeys.myList() : playlistsKeys.list(filters) const queryParams = userId ? { userId, } : filters const query = useQuery({ // eslint-disable-next-line @tanstack/query/exhaustive-deps queryKey: key, queryFn: async ({ signal }) => { const response = await client.GET('/playlists', { params: { query: queryParams, }, signal, }) if (response.error) { throw (response as unknown as { error: Error }).error } return response.data }, placeholderData: keepPreviousData, }) return query } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/src/widgets/playlists/ui/playlists.tsx ================================================ import { useState } from 'react' import { DeletePlaylist } from '../../../features/playlists/delete-playlist/ui/delete-playlist.tsx' import { Pagination } from '../../../shared/ui/pagination/pagination.tsx' import { usePlaylistsQuery } from '../api/use-playlists-query.ts' type Props = { userId?: string onPlaylistSelected?: (playlistId: string) => void onPlaylistDeleted?: (playlistId: string) => void isSearchActive?: boolean } export const Playlists = ({ userId, onPlaylistSelected, onPlaylistDeleted, isSearchActive, }: Props) => { const [pageNumber, setPageNumber] = useState(1) const [search, setSearch] = useState('') const query = usePlaylistsQuery(userId, { search, pageNumber }) const handleSelectPlaylistClick = (playlistId: string) => { onPlaylistSelected?.(playlistId) } const handleDeletePlaylist = (playlistId: string) => { onPlaylistDeleted?.(playlistId) } if (query.isPending) return Loading... if (query.isError) return Error: {JSON.stringify(query.error.message)} return (
    {isSearchActive && ( <>
    setSearch(e.currentTarget.value)} placeholder={'search...'} />

    )}
      {query.data.data.map((playlist) => (
    • handleSelectPlaylistClick(playlist.id)}> {playlist.attributes.title} {' '}
    • ))}
    ) } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/tsconfig.app.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "noUncheckedIndexedAccess": true }, "include": ["src"] } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/tsconfig.json ================================================ { "files": [], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/tsconfig.node.json ================================================ { "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "target": "ES2023", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/tsr.config.json ================================================ { "routesDirectory": "./src/app/routes", "generatedRouteTree": "./src/app/routes/routeTree.gen.ts" } ================================================ FILE: youtube/tanstack-query-router-fsd/lesson1/vite.config.ts ================================================ import { tanstackRouter } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ plugins: [ tanstackRouter({ target: 'react', autoCodeSplitting: true, }), react(), ], })