Merge branch 'main' into feat/adc/search-view-v2

This commit is contained in:
Amaury Danis Cousandier
2024-01-13 08:32:32 +01:00
7 changed files with 110 additions and 221 deletions

View File

@@ -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);
}
}

View File

@@ -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<Song[] | null> {
): Promise<Song[]> {
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<Artist[] | null> {
): Promise<Artist[]> {
return await this.searchService.searchArtists(
query,
query ?? "",
mapInclude(include, req, ArtistController.includableFields),
skip,
take,

View File

@@ -122,7 +122,7 @@ export class UsersService {
return this.prisma.user.update({
where: { id: where },
data: {
partyPlayed: {
totalScore: {
increment: score,
},
},

View File

@@ -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;

View File

@@ -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<MusicItemType[]>;
/**
* 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
* <MusicList
* initialMusics={initialMusicData}
* loadMoreMusics={(page, currentMusics) => 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(
() => (
<HStack
space={isBigScreen ? 1 : 2}
return (
<HStack
space={isBigScreen ? 1 : 2}
style={{
backgroundColor: colors.coolGray[500],
paddingHorizontal: isBigScreen ? 8 : 16,
paddingVertical: 12,
marginBottom: 2,
}}
>
<Text
fontSize="lg"
style={{
backgroundColor: colors.coolGray[500],
paddingHorizontal: isBigScreen ? 8 : 16,
paddingVertical: 12,
marginBottom: 2,
flex: 4,
width: '100%',
justifyContent: 'center',
paddingRight: 60,
}}
>
<Text
fontSize="lg"
style={{
flex: 4,
width: '100%',
justifyContent: 'center',
paddingRight: 60,
}}
>
{translate('musicListTitleSong')}
</Text>
{[
{ text: translate('musicListTitleLastScore'), icon: ArrowRotateLeft },
{ text: translate('musicListTitleBestScore'), icon: Cup },
].map((value) => (
<MusicItemTitle
key={value.text + 'key'}
text={value.text}
icon={value.icon}
isBigScreen={isBigScreen}
/>
))}
</HStack>
),
[colors.coolGray[500], isBigScreen]
{translate('musicListTitleSong')}
</Text>
<MusicItemTitle
text={translate('musicListTitleLastScore')}
icon={ArrowRotateLeft}
isBigScreen={isBigScreen}
/>
<MusicItemTitle
text={translate('musicListTitleBestScore')}
icon={Cup}
isBigScreen={isBigScreen}
/>
</HStack>
);
};
function MusicListCC({
musics,
refetch,
hasMore,
isFetching,
fetchMore,
}: {
musics?: Song[];
refetch: () => Promise<unknown>;
hasMore?: boolean;
isFetching: boolean;
fetchMore?: () => Promise<unknown>;
}) {
const { mutateAsync } = useLikeSongMutation();
const navigation = useNavigation();
const { colors } = useTheme();
if (!musics) {
return <LoadingView />;
}
// FlatList: Renders list efficiently, only rendering visible items.
return (
<FlatList
style={styles.container}
ListHeaderComponent={headerComponent}
data={musicListState.displayedMusics}
renderItem={({ item }) => <MusicItem style={{ marginBottom: 2 }} {...item} />}
keyExtractor={(item) => item.artist + item.song}
ListHeaderComponent={Header}
data={musics}
renderItem={({ item: song }) => (
<MusicItem
artist={song.artist!.name}
song={song.name}
image={song.cover}
lastScore={song.lastScore}
bestScore={song.bestScore}
liked={song.isLiked!}
onLike={(state: boolean) => {
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 ? (
<View style={styles.footerContainer}>
{musicListState.loading ? (
{isFetching ? (
<ActivityIndicator color={colors.primary[300]} />
) : (
<ButtonBase
style={{ borderRadius: 999 }}
onPress={loadMoreMusicItems}
onPress={() => {
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;

View File

@@ -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<typeof SongValidator>;

View File

@@ -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 <LoadingView />;
}
return <MusicList initialMusics={musics} musicsPerPage={25} />;
};
import { useState } from 'react';
import MusicListCC from '../components/UI/MusicList';
const FavoritesMusic = () => {
const likedSongs = useQuery(API.getLikedSongs(['artist', 'SongHistory', 'likedByUsers']));
return (
<MusicListCC
data={likedSongs.data?.map((x) => 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 (
<MusicListCC
data={
playHistory.data?.filter((x) => 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 (
<MusicListCC
data={nextStep.data ?? []}
isLoading={nextStep.isLoading}
musics={nextStep.data}
refetch={nextStep.refetch}
isFetching={nextStep.isFetching}
/>
);
};
@@ -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';