diff --git a/back/src/song/song.service.ts b/back/src/song/song.service.ts index 4a7a285..de7aa02 100644 --- a/back/src/song/song.service.ts +++ b/back/src/song/song.service.ts @@ -6,6 +6,14 @@ import { PrismaService } from 'src/prisma/prisma.service'; export class SongService { constructor(private prisma: PrismaService) {} + async songByArtist(data: number): Promise { + return this.prisma.song.findMany({ + where: { + artistId: {equals: data}, + }, + }); + } + async createSong(data: Prisma.SongCreateInput): Promise { return this.prisma.song.create({ data, diff --git a/front/API.ts b/front/API.ts index 1cc9fe2..c8555e4 100644 --- a/front/API.ts +++ b/front/API.ts @@ -287,6 +287,43 @@ export default class API { ), }; } + + /** + * @description retrieves songs from a specific artist + * @param artistId is the id of the artist that composed the songs aimed + * @returns a Promise of Songs type array + */ + public static getSongsByArtist(artistId: number): Query { + return { + key: ['artist', artistId, 'songs'], + exec: () => + API.fetch( + { + route: `/song?artistId=${artistId}`, + }, + { handler: PlageHandler(SongHandler) } + ).then(({ data }) => data), + }; + } + + /** + * Retrieves all songs corresponding to the given genre ID + * @param genreId the id of the genre we're aiming + * @returns a promise of an array of Songs + */ + public static getSongsByGenre(genreId: number): Query { + return { + key: ['genre', genreId, 'songs'], + exec: () => + API.fetch( + { + route: `/song?genreId=${genreId}`, + }, + { handler: PlageHandler(SongHandler) } + ).then(({ data }) => data), + }; + } + /** * Retrive a song's midi partition * @param songId the id to find the song @@ -322,6 +359,23 @@ export default class API { return `${API.baseUrl}/genre/${genreId}/illustration`; } + /** + * Retrieves a genre + * @param genreId the id of the aimed genre + */ + public static getGenre(genreId: number): Query { + return { + key: ['genre', genreId], + exec: () => + API.fetch( + { + route: `/genre/${genreId}`, + }, + { handler: GenreHandler } + ), + }; + } + /** * Retrive a song's musicXML partition * @param songId the id to find the song diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 10c759a..1e12973 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -28,6 +28,7 @@ import { Button, Center, VStack } from 'native-base'; import { unsetAccessToken } from './state/UserSlice'; import TextButton from './components/TextButton'; import ErrorView from './views/ErrorView'; +import GenreDetailsView from './views/GenreDetailsView'; import GoogleView from './views/GoogleView'; // Util function to hide route props in URL @@ -59,6 +60,11 @@ const protectedRoutes = () => options: { title: translate('artistFilter') }, link: '/artist/:artistId', }, + Genre: { + component: GenreDetailsView, + options: { title: translate('genreFilter') }, + link: '/genre/:genreId', + }, Score: { component: ScoreView, options: { title: translate('score'), headerLeft: null }, diff --git a/front/components/RowCustom.tsx b/front/components/RowCustom.tsx new file mode 100644 index 0000000..aa9d4e3 --- /dev/null +++ b/front/components/RowCustom.tsx @@ -0,0 +1,34 @@ +import { useColorScheme } from 'react-native'; +import { RootState, useSelector } from '../state/Store'; +import { Box, Pressable } from 'native-base'; + +const RowCustom = (props: Parameters[0] & { onPress?: () => void }) => { + const settings = useSelector((state: RootState) => state.settings.local); + const systemColorMode = useColorScheme(); + const colorScheme = settings.colorScheme; + + return ( + + {({ isHovered, isPressed }) => ( + + {props.children} + + )} + + ); +}; + +export default RowCustom; diff --git a/front/components/SearchResult.tsx b/front/components/SearchResult.tsx index 274cf54..bcbc60d 100644 --- a/front/components/SearchResult.tsx +++ b/front/components/SearchResult.tsx @@ -1,20 +1,16 @@ import React, { useMemo } from 'react'; import { - HStack, VStack, Heading, Text, - Pressable, Box, Card, - Image, Flex, useBreakpointValue, Column, ScrollView, } from 'native-base'; -import { SafeAreaView, useColorScheme } from 'react-native'; -import { RootState, useSelector } from '../state/Store'; +import { SafeAreaView } from 'react-native'; import { SearchContext } from '../views/SearchView'; import { useQueries, useQuery } from '../Queries'; import { translate } from '../i18n/i18n'; @@ -24,11 +20,11 @@ import ArtistCard from './ArtistCard'; import GenreCard from './GenreCard'; import SongCard from './SongCard'; import CardGridCustom from './CardGridCustom'; -import TextButton from './TextButton'; import SearchHistoryCard from './HistoryCard'; import Song, { SongWithArtist } from '../models/Song'; import { useNavigation } from '../Navigation'; import Artist from '../models/Artist'; +import SongRow from '../components/SongRow'; const swaToSongCardProps = (song: SongWithArtist) => ({ songId: song.id, @@ -37,101 +33,6 @@ const swaToSongCardProps = (song: SongWithArtist) => ({ cover: song.cover ?? 'https://picsum.photos/200', }); -const RowCustom = (props: Parameters[0] & { onPress?: () => void }) => { - const settings = useSelector((state: RootState) => state.settings.local); - const systemColorMode = useColorScheme(); - const colorScheme = settings.colorScheme; - - return ( - - {({ isHovered, isPressed }) => ( - - {props.children} - - )} - - ); -}; - -type SongRowProps = { - song: Song | SongWithArtist; // TODO: remove Song - onPress: () => void; -}; - -const SongRow = ({ song, onPress }: SongRowProps) => { - return ( - - - {song.name} - - - {song.name} - - - {song.artistId ?? 'artist'} - - - - - - ); -}; - -SongRow.defaultProps = { - onPress: () => {}, -}; - const HomeSearchComponent = () => { const { updateStringQuery } = React.useContext(SearchContext); const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery( @@ -252,15 +153,17 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => { {artistData?.length ? ( ({ - image: API.getArtistIllustration(a.id), - name: a.name, - id: a.id, - onPress: () => { - API.createSearchHistoryEntry(a.name, 'artist'); - navigation.navigate('Artist', { artistId: a.id }); - }, - }))} + content={artistData + .slice(0, props.maxItems ?? artistData.length) + .map((artistData) => ({ + image: API.getArtistIllustration(artistData.id), + name: artistData.name, + id: artistData.id, + onPress: () => { + API.createSearchHistoryEntry(artistData.name, 'artist'); + navigation.navigate('Artist', { artistId: artistData.id }); + }, + }))} cardComponent={ArtistCard} /> ) : ( @@ -287,7 +190,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => { id: g.id, onPress: () => { API.createSearchHistoryEntry(g.name, 'genre'); - navigation.navigate('Home'); + navigation.navigate('Genre', { genreId: g.id }); }, }))} cardComponent={GenreCard} diff --git a/front/components/SongRow.tsx b/front/components/SongRow.tsx new file mode 100644 index 0000000..4a61c83 --- /dev/null +++ b/front/components/SongRow.tsx @@ -0,0 +1,71 @@ +import { HStack, Image, Text } from 'native-base'; +import Song, { SongWithArtist } from '../models/Song'; +import RowCustom from './RowCustom'; +import TextButton from './TextButton'; + +type SongRowProps = { + song: Song | SongWithArtist; // TODO: remove Song + onPress: () => void; +}; + +const SongRow = ({ song, onPress }: SongRowProps) => { + return ( + + + {song.name} + + + {song.name} + + + {song.artistId ?? 'artist'} + + + + + + ); +}; + +export default SongRow; diff --git a/front/i18n/Translations.ts b/front/i18n/Translations.ts index ac86413..07684ea 100644 --- a/front/i18n/Translations.ts +++ b/front/i18n/Translations.ts @@ -43,6 +43,7 @@ export const en = { artistFilter: 'Artists', songsFilter: 'Songs', genreFilter: 'Genres', + favoriteFilter: 'Favorites', // profile page user: 'Profile', @@ -231,6 +232,7 @@ export const fr: typeof en = { artistFilter: 'Artistes', songsFilter: 'Morceaux', genreFilter: 'Genres', + favoriteFilter: 'Favoris', // Difficulty settings diffBtn: 'Difficulté', @@ -428,6 +430,7 @@ export const sp: typeof en = { artistFilter: 'Artistas', songsFilter: 'canciones', genreFilter: 'géneros', + favoriteFilter: 'Favorites', // Difficulty settings diffBtn: 'Dificultad', diff --git a/front/views/ArtistDetailsView.tsx b/front/views/ArtistDetailsView.tsx index cc567bd..61a0328 100644 --- a/front/views/ArtistDetailsView.tsx +++ b/front/views/ArtistDetailsView.tsx @@ -1,49 +1,58 @@ -import { VStack, Image, Heading, IconButton, Icon, Container } from 'native-base'; -import { Ionicons } from '@expo/vector-icons'; -import { SafeAreaView } from 'react-native'; +import { Box, Heading, useBreakpointValue, ScrollView } from 'native-base'; import { useQuery } from '../Queries'; import { LoadingView } from '../components/Loading'; import API from '../API'; -import { useNavigation } from '../Navigation'; - -const handleFavorite = () => {}; +import Song from '../models/Song'; +import SongRow from '../components/SongRow'; +import { Key } from 'react'; +import { RouteProps, useNavigation } from '../Navigation'; +import { ImageBackground } from 'react-native'; type ArtistDetailsViewProps = { artistId: number; }; -const ArtistDetailsView = ({ artistId }: ArtistDetailsViewProps) => { +const ArtistDetailsView = ({ artistId }: RouteProps) => { + const artistQuery = useQuery(API.getArtist(artistId)); + const songsQuery = useQuery(API.getSongsByArtist(artistId)); + const screenSize = useBreakpointValue({ base: 'small', md: 'big' }); + const isMobileView = screenSize == 'small'; const navigation = useNavigation(); - const { isLoading, data: artistData, isError } = useQuery(API.getArtist(artistId)); - if (isLoading) { + if (artistQuery.isError || songsQuery.isError) { + navigation.navigate('Error'); + return <>; + } + if (!artistQuery.data || songsQuery.data === undefined) { return ; } - if (isError) { - navigation.navigate('Error'); - } - return ( - - - {artistData?.name} - - {artistData?.name} - } - onPress={() => handleFavorite()} - variant="unstyled" - _pressed={{ opacity: 0.6 }} - /> - - - + + + + + {artistQuery.data.name} + + + + {songsQuery.data.map((comp: Song, index: Key | null | undefined) => ( + { + API.createSearchHistoryEntry(comp.name, 'song'); + navigation.navigate('Song', { songId: comp.id }); + }} + /> + ))} + + + + ); }; diff --git a/front/views/GenreDetailsView.tsx b/front/views/GenreDetailsView.tsx new file mode 100644 index 0000000..e9c632e --- /dev/null +++ b/front/views/GenreDetailsView.tsx @@ -0,0 +1,74 @@ +import { Flex, Heading, useBreakpointValue, ScrollView } from 'native-base'; +import { useQueries, useQuery } from '../Queries'; +import { LoadingView } from '../components/Loading'; +import { RouteProps, useNavigation } from '../Navigation'; +import API from '../API'; +import CardGridCustom from '../components/CardGridCustom'; +import SongCard from '../components/SongCard'; +import { ImageBackground } from 'react-native'; + +type GenreDetailsViewProps = { + genreId: number; +}; + +const GenreDetailsView = ({ genreId }: RouteProps) => { + const genreQuery = useQuery(API.getGenre(genreId)); + const songsQuery = useQuery(API.getSongsByGenre(genreId)); + const artistQueries = useQueries( + songsQuery.data?.map((song) => song.artistId).map((artistId) => API.getArtist(artistId)) ?? + [] + ); + // Here, .artist will always be defined + const songWithArtist = songsQuery?.data + ?.map((song) => ({ + ...song, + artist: artistQueries.find((query) => query.data?.id == song.artistId)?.data, + })) + .filter((song) => song.artist !== undefined); + + const screenSize = useBreakpointValue({ base: 'small', md: 'big' }); + const isMobileView = screenSize == 'small'; + const navigation = useNavigation(); + + if (genreQuery.isError || songsQuery.isError) { + navigation.navigate('Error'); + return <>; + } + if (!genreQuery.data || songsQuery.data === undefined || songWithArtist === undefined) { + return ; + } + + return ( + + + + {genreQuery.data.name} + + + ({ + name: songData.name, + cover: songData.cover, + artistName: songData.artist!.name, + songId: songData.id, + onPress: () => { + API.createSearchHistoryEntry(songData.name, 'song'); + navigation.navigate('Song', { songId: songData.id }); + }, + }))} + cardComponent={SongCard} + /> + + + ); +}; + +export default GenreDetailsView; diff --git a/front/yarn.lock b/front/yarn.lock index dc19c3a..194e2f6 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -9118,6 +9118,11 @@ expo-keep-awake@~11.0.1: resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-11.0.1.tgz#ee354465892a94040ffe09901b85b469e7d54fb3" integrity sha512-44ZjgLE4lnce2d40Pv8xsjMVc6R5GvgHOwZfkLYtGmgYG9TYrEJeEj5UfSeweXPL3pBFhXKfFU8xpGYMaHdP0A== +expo-linear-gradient@^12.3.0: + version "12.3.0" + resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-12.3.0.tgz#7abd8fedbf0138c86805aebbdfbbf5e5fa865f19" + integrity sha512-f9e+Oxe5z7fNQarTBZXilMyswlkbYWQHONVfq8MqmiEnW3h9XsxxmVJLG8uVQSQPUsbW+x1UUT/tnU6mkMWeLg== + expo-linking@~3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-3.3.1.tgz#253b183321e54cb6fa1a667a53d4594aa88a3357"