diff --git a/back/src/auth/auth.controller.ts b/back/src/auth/auth.controller.ts index 9648713..14e6e9d 100644 --- a/back/src/auth/auth.controller.ts +++ b/back/src/auth/auth.controller.ts @@ -21,6 +21,7 @@ import { Response, Query, Param, + ParseIntPipe, } from "@nestjs/common"; import { AuthService } from "./auth.service"; import { JwtAuthGuard } from "./jwt-auth.guard"; @@ -287,8 +288,8 @@ export class AuthController { @ApiOkResponse({ description: "Successfully added liked song" }) @ApiUnauthorizedResponse({ description: "Invalid token" }) @Post("me/likes/:id") - addLikedSong(@Request() req: any, @Param("id") songId: number) { - return this.usersService.addLikedSong(+req.user.id, +songId); + addLikedSong(@Request() req: any, @Param("id", ParseIntPipe) songId: number) { + return this.usersService.addLikedSong(+req.user.id, songId); } @UseGuards(JwtAuthGuard) @@ -296,8 +297,11 @@ export class AuthController { @ApiOkResponse({ description: "Successfully removed liked song" }) @ApiUnauthorizedResponse({ description: "Invalid token" }) @Delete("me/likes/:id") - removeLikedSong(@Request() req: any, @Param("id") songId: number) { - return this.usersService.removeLikedSong(+req.user.id, +songId); + removeLikedSong( + @Request() req: any, + @Param("id", ParseIntPipe) songId: number, + ) { + return this.usersService.removeLikedSong(+req.user.id, songId); } @UseGuards(JwtAuthGuard) @@ -317,7 +321,7 @@ export class AuthController { @ApiOkResponse({ description: "Successfully added score" }) @ApiUnauthorizedResponse({ description: "Invalid token" }) @Patch("me/score/:score") - addScore(@Request() req: any, @Param("id") score: number) { + addScore(@Request() req: any, @Param("score", ParseIntPipe) score: number) { return this.usersService.addScore(+req.user.id, score); } } diff --git a/back/src/search/search.controller.ts b/back/src/search/search.controller.ts index 5b51e25..391c896 100644 --- a/back/src/search/search.controller.ts +++ b/back/src/search/search.controller.ts @@ -2,9 +2,6 @@ import { Controller, DefaultValuePipe, Get, - InternalServerErrorException, - NotFoundException, - Param, ParseIntPipe, Query, Request, @@ -16,15 +13,13 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { Artist, Genre, Song } from "@prisma/client"; +import { Artist, Song } from "@prisma/client"; import { JwtAuthGuard } from "src/auth/jwt-auth.guard"; import { SearchService } from "./search.service"; import { Song as _Song } from "src/_gen/prisma-class/song"; -import { Genre as _Genre } from "src/_gen/prisma-class/genre"; import { Artist as _Artist } from "src/_gen/prisma-class/artist"; import { mapInclude } from "src/utils/include"; import { SongController } from "src/song/song.controller"; -import { GenreController } from "src/genre/genre.controller"; import { ArtistController } from "src/artist/artist.controller"; @ApiTags("search") @@ -39,15 +34,15 @@ export class SearchController { @ApiUnauthorizedResponse({ description: "Invalid token" }) async searchSong( @Request() req: any, - @Param("query") query: string, + @Query("q") query: string | null, @Query("artistId") artistId: number, @Query("genreId") genreId: number, @Query("include") include: string, @Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number, @Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number, - ): Promise { + ): Promise { return await this.searchService.searchSong( - query, + query ?? "", artistId, genreId, mapInclude(include, req, SongController.includableFields), @@ -64,12 +59,12 @@ export class SearchController { async searchArtists( @Request() req: any, @Query("include") include: string, - @Param("query") query: string, + @Query("q") query: string | null, @Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number, @Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number, - ): Promise { + ): Promise { return await this.searchService.searchArtists( - query, + query ?? "", mapInclude(include, req, ArtistController.includableFields), skip, take, diff --git a/back/src/users/users.service.ts b/back/src/users/users.service.ts index b18b47a..a9ac7be 100644 --- a/back/src/users/users.service.ts +++ b/back/src/users/users.service.ts @@ -122,7 +122,7 @@ export class UsersService { return this.prisma.user.update({ where: { id: where }, data: { - partyPlayed: { + totalScore: { increment: score, }, }, diff --git a/front/components/UI/MusicItem.tsx b/front/components/UI/MusicItem.tsx index 8d12953..6964387 100644 --- a/front/components/UI/MusicItem.tsx +++ b/front/components/UI/MusicItem.tsx @@ -184,8 +184,6 @@ function MusicItemComponent(props: MusicItemType) { ); } -// Using `memo` to optimize rendering performance by memorizing the component's output. -// This ensures that the component only re-renders when its props change. const MusicItem = memo(MusicItemComponent); export default MusicItem; diff --git a/front/components/UI/MusicList.tsx b/front/components/UI/MusicList.tsx index c3a2bd0..eddd92c 100644 --- a/front/components/UI/MusicList.tsx +++ b/front/components/UI/MusicList.tsx @@ -1,10 +1,14 @@ -import React, { useCallback, useState, useMemo, memo } from 'react'; +import { memo } from 'react'; import { FlatList, HStack, View, useBreakpointValue, useTheme, Text, Row } from 'native-base'; import { ActivityIndicator, StyleSheet } from 'react-native'; -import MusicItem, { MusicItemType } from './MusicItem'; +import MusicItem from './MusicItem'; import ButtonBase from './ButtonBase'; import { ArrowDown2, ArrowRotateLeft, Cup, Icon } from 'iconsax-react-native'; import { translate } from '../../i18n/i18n'; +import Song from '../../models/Song'; +import { useLikeSongMutation } from '../../utils/likeSongMutation'; +import { useNavigation } from '../../Navigation'; +import { LoadingView } from '../Loading'; // Props type definition for MusicItemTitle. interface MusicItemTitleProps { @@ -47,167 +51,99 @@ function MusicItemTitleComponent(props: MusicItemTitleProps) { // MusicItemTitle component, memoized for performance. const MusicItemTitle = memo(MusicItemTitleComponent); -/** - * Define the type for the MusicList component props. - */ -type MusicListProps = { - /** - * Music items available for display. Not all items may be displayed initially; - * depends on 'musicsPerPage'. - */ - initialMusics: MusicItemType[]; - - /** - * Function to load more music items asynchronously. Called with current page number - * and the list of all music items. Should return a Promise with additional music items. - */ - loadMoreMusics?: (page: number, musics: MusicItemType[]) => Promise; - - /** - * Number of music items to display per page. Determines initial and additional items displayed. - */ - musicsPerPage?: number; -}; - -/** - * `MusicList` Component - * - * A responsive and dynamic list component designed for displaying a collection of music items. - * It allows for loading and rendering an initial set of music items and provides functionality - * to load more items dynamically as needed. - * - * Features: - * - Dynamically loads and displays music items based on the provided `initialMusics` and `musicsPerPage`. - * - Supports pagination through the `loadMoreMusics` function, which loads additional music items when invoked. - * - Adapts its layout responsively based on screen size for optimal viewing across different devices. - * - Includes a loading indicator to inform users when additional items are being loaded. - * - Conditionally renders a 'Load More' button to fetch more music items, hidden when no more items are available. - * - * Usage: - * - * ```jsx - * loadAdditionalMusics(page, currentMusics)} - * musicsPerPage={10} - * /> - * ``` - * - * Note: - * - The `MusicList` is designed to handle a potentially large number of music items efficiently, - * making it suitable for use cases where the list of items is expected to grow over time. - * - The layout and styling are optimized for performance and responsiveness. - */ -function MusicListComponent({ - initialMusics, - loadMoreMusics, - musicsPerPage = loadMoreMusics ? 50 : initialMusics.length, -}: MusicListProps) { - // State initialization for MusicList. - // 'allMusics': all music items. - // 'displayedMusics': items displayed per page. - // 'currentPage': current page in pagination. - // 'loading': indicates if more items are being loaded. - // 'hasMoreMusics': flag for more items availability. - const [musicListState, setMusicListState] = useState({ - allMusics: initialMusics, - displayedMusics: initialMusics.slice(0, musicsPerPage), - currentPage: 1, - loading: false, - hasMoreMusics: initialMusics.length > musicsPerPage || !!loadMoreMusics, - }); +const Header = () => { const { colors } = useTheme(); const screenSize = useBreakpointValue({ base: 'small', md: 'md', xl: 'xl' }); const isBigScreen = screenSize === 'xl'; - // Loads additional music items. - // Uses useCallback to avoid unnecessary redefinitions on re-renders. - const loadMoreMusicItems = useCallback(async () => { - if (musicListState.loading || !musicListState.hasMoreMusics) { - return; - } - - setMusicListState((prevState) => ({ ...prevState, loading: true })); - - let hasMoreMusics = true; - const nextEndIndex = (musicListState.currentPage + 1) * musicsPerPage; - let updatedAllMusics = musicListState.allMusics; - - if (loadMoreMusics && updatedAllMusics.length <= nextEndIndex) { - const newMusics = await loadMoreMusics(musicListState.currentPage, updatedAllMusics); - updatedAllMusics = [...updatedAllMusics, ...newMusics]; - hasMoreMusics = newMusics.length > 0; - } else { - hasMoreMusics = updatedAllMusics.length > nextEndIndex; - } - - setMusicListState((prevState) => ({ - ...prevState, - allMusics: updatedAllMusics, - displayedMusics: updatedAllMusics.slice(0, nextEndIndex), - currentPage: prevState.currentPage + 1, - loading: false, - hasMoreMusics: hasMoreMusics, - })); - }, [musicsPerPage, loadMoreMusics, musicListState]); - - // useMemo to optimize performance by memorizing the header, - // preventing unnecessary re-renders. - const headerComponent = useMemo( - () => ( - + - - {translate('musicListTitleSong')} - - {[ - { text: translate('musicListTitleLastScore'), icon: ArrowRotateLeft }, - { text: translate('musicListTitleBestScore'), icon: Cup }, - ].map((value) => ( - - ))} - - ), - [colors.coolGray[500], isBigScreen] + {translate('musicListTitleSong')} + + + + ); +}; + +function MusicListCC({ + musics, + refetch, + hasMore, + isFetching, + fetchMore, +}: { + musics?: Song[]; + refetch: () => Promise; + hasMore?: boolean; + isFetching: boolean; + fetchMore?: () => Promise; +}) { + const { mutateAsync } = useLikeSongMutation(); + const navigation = useNavigation(); + const { colors } = useTheme(); + + if (!musics) { + return ; + } - // FlatList: Renders list efficiently, only rendering visible items. return ( } - keyExtractor={(item) => item.artist + item.song} + ListHeaderComponent={Header} + data={musics} + renderItem={({ item: song }) => ( + { + mutateAsync({ songId: song.id, like: state }).then(() => refetch()); + }} + onPlay={() => navigation.navigate('Play', { songId: song.id })} + style={{ marginBottom: 2 }} + /> + )} + keyExtractor={(item) => item.id.toString()} ListFooterComponent={ - musicListState.hasMoreMusics ? ( + hasMore ? ( - {musicListState.loading ? ( + {isFetching ? ( ) : ( { + fetchMore?.(); + }} icon={ArrowDown2} /> )} @@ -232,10 +168,4 @@ const styles = StyleSheet.create({ }, }); -// Using `memo` to optimize rendering performance by memorizing the component's output. -// This ensures that the component only re-renders when its props change. -const MusicList = memo(MusicListComponent, (prev, next) => { - return prev.initialMusics.length == next.initialMusics.length; -}); - -export default MusicList; +export default MusicListCC; diff --git a/front/models/Song.ts b/front/models/Song.ts index c696686..ec2efee 100644 --- a/front/models/Song.ts +++ b/front/models/Song.ts @@ -35,6 +35,7 @@ export const SongValidator = yup yup.array(yup.object({ userId: yup.number().required() })).default(undefined) ) .optional(), + isLiked: yup.bool().optional(), }) .concat(ModelValidator) .transform((song: Song) => ({ @@ -53,6 +54,7 @@ export const SongValidator = yup yup.date().cast(b.playDate)!.getTime() ) .at(0)?.info.score ?? null, + isLiked: song.likedByUsers?.some(() => true), })); export type Song = yup.InferType; diff --git a/front/views/MusicView.tsx b/front/views/MusicView.tsx index 3bfb4b4..21a71b0 100644 --- a/front/views/MusicView.tsx +++ b/front/views/MusicView.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useBreakpointValue, useTheme } from 'native-base'; import { useWindowDimensions } from 'react-native'; import { @@ -11,57 +10,20 @@ import { } from 'react-native-tab-view'; import { Heart, Clock, StatusUp, FolderCross } from 'iconsax-react-native'; import { Scene } from 'react-native-tab-view/lib/typescript/src/types'; -import { useNavigation } from '../Navigation'; import { Translate, TranslationKey } from '../i18n/i18n'; -import MusicList from '../components/UI/MusicList'; import { useQuery } from '../Queries'; import API from '../API'; -import { LoadingView } from '../components/Loading'; -import { useLikeSongMutation } from '../utils/likeSongMutation'; import Song from '../models/Song'; - -type MusicListCCProps = { - data: Song[] | undefined; - isLoading: boolean; - refetch: () => void; -}; - -const MusicListCC = ({ data, isLoading, refetch }: MusicListCCProps) => { - const navigation = useNavigation(); - const { mutateAsync } = useLikeSongMutation(); - const user = useQuery(API.getUserInfo); - - const musics = (data ?? []).map((song) => { - const isLiked = song.likedByUsers?.some(({ userId }) => userId === user.data?.id) ?? false; - - return { - artist: song.artist!.name, - song: song.name, - image: song.cover, - lastScore: song.lastScore, - bestScore: song.bestScore, - liked: isLiked, - onLike: (state: boolean) => { - mutateAsync({ songId: song.id, like: state }).then(() => refetch()); - }, - onPlay: () => navigation.navigate('Play', { songId: song.id }), - }; - }); - - if (isLoading) { - return ; - } - - return ; -}; +import { useState } from 'react'; +import MusicListCC from '../components/UI/MusicList'; const FavoritesMusic = () => { const likedSongs = useQuery(API.getLikedSongs(['artist', 'SongHistory', 'likedByUsers'])); return ( x.song)} - isLoading={likedSongs.isLoading} + musics={likedSongs.data?.map((x) => x.song)} refetch={likedSongs.refetch} + isFetching={likedSongs.isFetching} /> ); }; @@ -70,11 +32,9 @@ const RecentlyPlayedMusic = () => { const playHistory = useQuery(API.getUserPlayHistory(['artist', 'SongHistory', 'likedByUsers'])); return ( x.song !== undefined).map((x) => x.song) as Song[] - } - isLoading={playHistory.isLoading} + musics={playHistory.data?.map((x) => x.song) as Song[]} refetch={playHistory.refetch} + isFetching={playHistory.isFetching} /> ); }; @@ -83,9 +43,9 @@ const StepUpMusic = () => { const nextStep = useQuery(API.getSongSuggestions(['artist', 'SongHistory', 'likedByUsers'])); return ( ); }; @@ -111,7 +71,7 @@ const getTabData = (key: string) => { const MusicTab = () => { const layout = useWindowDimensions(); - const [index, setIndex] = React.useState(0); + const [index, setIndex] = useState(0); const { colors } = useTheme(); const screenSize = useBreakpointValue({ base: 'small', md: 'big' }); const isSmallScreen = screenSize === 'small';