diff --git a/back/src/history/dto/SearchHistoryDto.ts b/back/src/history/dto/SearchHistoryDto.ts index efd6f82..3ecbba6 100644 --- a/back/src/history/dto/SearchHistoryDto.ts +++ b/back/src/history/dto/SearchHistoryDto.ts @@ -1,14 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber } from "class-validator"; export class SearchHistoryDto { - @ApiProperty() - @IsNumber() - userID: number; - @ApiProperty() query: string; @ApiProperty() - type: "song" | "artist" | "album"; + type: "song" | "artist" | "album" | "genre"; } diff --git a/back/src/history/history.controller.ts b/back/src/history/history.controller.ts index 646a7b6..53d809f 100644 --- a/back/src/history/history.controller.ts +++ b/back/src/history/history.controller.ts @@ -15,6 +15,7 @@ import { SearchHistory, SongHistory } from '@prisma/client'; import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; import { SongHistoryDto } from './dto/SongHistoryDto'; import { HistoryService } from './history.service'; +import { SearchHistoryDto } from './dto/SearchHistoryDto'; @Controller('history') @ApiTags('history') @@ -50,4 +51,15 @@ export class HistoryController { async create(@Body() record: SongHistoryDto): Promise { return this.historyService.createSongHistoryRecord(record); } + + @Post("search") + @HttpCode(201) + @UseGuards(JwtAuthGuard) + @ApiUnauthorizedResponse({description: "Invalid token"}) + async createSearchHistory( + @Request() req: any, + @Body() record: SearchHistoryDto + ): Promise { + await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type }); + } } diff --git a/back/src/history/history.service.ts b/back/src/history/history.service.ts index 3ae9809..8d89435 100644 --- a/back/src/history/history.service.ts +++ b/back/src/history/history.service.ts @@ -72,11 +72,10 @@ export class HistoryService { }; } - async createSearchHistoryRecord({ - userID, - query, - type, - }: SearchHistoryDto): Promise { + async createSearchHistoryRecord( + userID: number, + { query, type }: SearchHistoryDto + ): Promise { return this.prisma.searchHistory.create({ data: { query, diff --git a/back/src/search/search.controller.ts b/back/src/search/search.controller.ts index cc7e405..5a19871 100644 --- a/back/src/search/search.controller.ts +++ b/back/src/search/search.controller.ts @@ -13,7 +13,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; -import { Song } from '@prisma/client'; +import { Artist, Genre, Song } from '@prisma/client'; import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; import { SearchSongDto } from './dto/search-song.dto'; import { SearchService } from './search.service'; @@ -23,117 +23,39 @@ import { SearchService } from './search.service'; export class SearchController { constructor(private readonly searchService: SearchService) { } - @ApiOperation({ - summary: 'Get a song details by song name', - description: 'Get a song details by song name', - }) - @Get('song/:name') + @Get('songs/:query') @UseGuards(JwtAuthGuard) - async findByName(@Request() req: any, @Param('name') name: string): Promise { - const ret = await this.searchService.songByTitle({ name }, req.user?.id); - if (!ret) throw new NotFoundException(); - return ret; - } - - @ApiOperation({ - summary: 'Get songs details by advanced filter', - description: 'Get songs details by advanced filter', - }) - @Post('song/advanced') - @HttpCode(200) // change from '201 created' to '200 OK' http default response code - async findAdvanced( - @Body() searchSongDto: SearchSongDto, - ): Promise { + async searchSong(@Request() req: any, @Param('query') query: string): Promise { try { - const ret = await this.searchService.findAdvanced({ - albumId: searchSongDto.album ? +searchSongDto.album : undefined, - artistId: searchSongDto.artist ? +searchSongDto.artist : undefined, - genreId: searchSongDto.genre ? +searchSongDto.genre : undefined, - }); + const ret = await this.searchService.songByGuess(query, req.user?.id); if (!ret.length) throw new NotFoundException(); else return ret; } catch (error) { - console.log(error); - throw new BadRequestException(null, error?.toString()); + throw new InternalServerErrorException(); } } - @ApiOperation({ - summary: 'Get songs details by artist', - description: 'Get songs details by artist', - }) - @Get('song/artist/:artistId') - async findByArtist( - @Param('artistId', ParseIntPipe) artistId: number, - ): Promise { - const ret = await this.searchService.songsByArtist(artistId); - if (!ret.length) throw new NotFoundException(); - else return ret; - } - - @ApiOperation({ - summary: 'Get songs details by genre', - description: 'Get songs details by genre', - }) - @Get('song/genre/:genreId') - async findByGenre( - @Param('genreId', ParseIntPipe) genreId: number, - ): Promise { - const ret = await this.searchService.songsByGenre(genreId); - if (!ret.length) throw new NotFoundException(); - else return ret; - } - - @ApiOperation({ - summary: 'Get songs details by album', - description: 'Get songs details by album', - }) - @Get('song/album/:albumId') - async findByAlbum( - @Param('albumId', ParseIntPipe) albumId: number, - ): Promise { - const ret = await this.searchService.songsByAlbum(albumId); - if (ret.length) throw new NotFoundException(); - else return ret; - } - - @ApiOperation({ - summary: 'Guess elements details by keyword', - description: 'Guess elements details by keyword', - }) - @Get('guess/:type/:word') - @ApiParam({ - name: 'word', - type: 'string', - required: true, - example: 'Yoko Shimomura', - }) - @ApiParam({ name: 'type', type: 'string', required: true, example: 'artist' }) + @Get('genres/:query') @UseGuards(JwtAuthGuard) - async guess( - @Request() req: any, - @Param() params: { type: string; word: string }, - ): Promise { + async searchGenre(@Request() req: any, @Param('query') query: string): Promise { try { - let ret: any[]; - switch (params.type) { - case 'artist': - ret = await this.searchService.guessArtist(params.word, req.user?.id); - break; - case 'album': - ret = await this.searchService.guessAlbum(params.word, req.user?.id); - break; - case 'song': - ret = await this.searchService.guessSong(params.word, req.user?.id); - break; - default: - throw new BadRequestException(); - } + const ret = await this.searchService.genreByGuess(query, req.user?.id); if (!ret.length) throw new NotFoundException(); else return ret; } catch (error) { - console.log(error); - throw new InternalServerErrorException(null, error?.toString()); + throw new InternalServerErrorException(); } } -} + + @Get('artists/:query') + @UseGuards(JwtAuthGuard) + async searchArtists(@Request() req: any, @Param('query') query: string): Promise { + try { + const ret = await this.searchService.artistByGuess(query, req.user?.id); + if (!ret.length) throw new NotFoundException(); + else return ret; + } catch (error) { + throw new InternalServerErrorException(); + } + } +} \ No newline at end of file diff --git a/back/src/search/search.service.ts b/back/src/search/search.service.ts index fb7c08c..77d6fce 100644 --- a/back/src/search/search.service.ts +++ b/back/src/search/search.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Album, Artist, Prisma, Song } from '@prisma/client'; +import { Album, Artist, Prisma, Song, Genre } from '@prisma/client'; import { HistoryService } from 'src/history/history.service'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -7,100 +7,27 @@ import { PrismaService } from 'src/prisma/prisma.service'; export class SearchService { constructor(private prisma: PrismaService, private history: HistoryService) { } - async songByTitle( - songWhereUniqueInput: Prisma.SongWhereUniqueInput, - userID: number - ): Promise { - if (songWhereUniqueInput.name) - await this.history.createSearchHistoryRecord({ query: songWhereUniqueInput.name, userID, type: "song" }); - return this.prisma.song.findUnique({ - where: songWhereUniqueInput, - }); - } - - async songsByArtist(artistId: number): Promise { + async songByGuess(query: string, userID: number): Promise { return this.prisma.song.findMany({ where: { - artistId: artistId, + name: { contains: query, mode: 'insensitive' }, }, - orderBy: [], }); } - async songsByGenre(genreId: number): Promise { - return this.prisma.song.findMany({ + async genreByGuess(query: string, userID: number): Promise { + return this.prisma.genre.findMany({ where: { - genreId: genreId, + name: { contains: query, mode: 'insensitive' }, }, }); } - async songsByAlbum(albumId: number): Promise { - return this.prisma.song.findMany({ - where: { - albumId: albumId, - }, - }); - } - - async artistByName(artistName: string): Promise { - return this.prisma.artist.findUnique({ - where: { - name: artistName, - }, - }); - } - - async guessSong(word: string, userID: number): Promise { - await this.history.createSearchHistoryRecord({ query: word, type: "song", userID }); - return this.prisma.song.findMany({ - where: { - name: { contains: word }, - }, - }); - } - - async guessArtist(word: string, userID: number): Promise { - await this.history.createSearchHistoryRecord({ query: word, type: "artist", userID }); + async artistByGuess(query: string, userID: number): Promise { return this.prisma.artist.findMany({ where: { - name: { contains: word }, + name: { contains: query, mode: 'insensitive' }, }, }); } - - async guessAlbum(word: string, userID: number): Promise { - await this.history.createSearchHistoryRecord({ query: word, type: "album", userID }); - return this.prisma.album.findMany({ - where: { - name: { contains: word }, - }, - }); - } - - async findAdvanced(params: { - albumId?: number; - genreId?: number; - artistId?: number; - orderBy?: Prisma.SongOrderByWithRelationInput; - }): Promise { - const { - albumId: albumId, - genreId: genreId, - artistId: artistId, - orderBy: orderBy, - } = params; - return this.prisma.song.findMany({ - where: { - OR: [ - { - albumId: { equals: albumId }, - genreId: { equals: genreId }, - artistId: { equals: artistId }, - }, - ], - }, - orderBy, - }); - } } diff --git a/back/test/robot/history/history.robot b/back/test/robot/history/history.robot index 2d8c8fd..9e8fb52 100644 --- a/back/test/robot/history/history.robot +++ b/back/test/robot/history/history.robot @@ -83,14 +83,17 @@ Create and get a search history record Output Integer response status 404 + POST /history/search + ... { "query": "tata", "type": "song" } + &{res}= GET /history/search Output Integer response status 200 Array response body String $[0].type "song" String $[0].query "tata" - String $[1].type "song" - String $[1].query "toto" + ${len}= Get Length ${res.body} + Should Be Equal As Integers ${len} 1 [Teardown] DELETE /users/${userID} diff --git a/front/API.ts b/front/API.ts index 0938b3a..d7287c7 100644 --- a/front/API.ts +++ b/front/API.ts @@ -1,7 +1,9 @@ import Artist from "./models/Artist"; +import Album from "./models/Album"; import AuthToken from "./models/AuthToken"; import Chapter from "./models/Chapter"; import Lesson from "./models/Lesson"; +import Genre from "./models/Genre"; import LessonHistory from "./models/LessonHistory"; import Song from "./models/Song"; import SongHistory from "./models/SongHistory"; @@ -10,7 +12,7 @@ import Constants from "expo-constants"; import store from "./state/Store"; import { Platform } from "react-native"; import { en } from "./i18n/Translations"; -import { QueryClient } from "react-query"; +import { useQuery, QueryClient } from "react-query"; import UserSettings from "./models/UserSettings"; import { PartialDeep } from "type-fest"; import SearchHistory from "./models/SearchHistory"; @@ -343,7 +345,51 @@ export default class API { */ public static async searchSongs(query: string): Promise { return API.fetch({ - route: `/search/guess/song/${query}`, + route: `/search/songs/${query}`, + }); + } + + /** + * Search artists by name + * @param query the string used to find the artists + */ + public static async searchArtists(query?: string): Promise { + return API.fetch({ + route: `/search/artists/${query}`, + }); + } + + /** + * Search Album by name + * @param query the string used to find the album + */ + public static async searchAlbum(query?: string): Promise { + return [ + { + id: 1, + name: "Super Trooper", + }, + { + id: 2, + name: "Kingdom Heart 365/2 OST", + }, + { + id: 3, + name: "The Legend Of Zelda Ocarina Of Time OST", + }, + { + id: 4, + name: "Random Access Memories", + }, + ] as Album[]; + } + + /** + * Retrieve music genres + */ + public static async searchGenres(query?: string): Promise { + return API.fetch({ + route: `/search/genres/${query}`, }); } @@ -363,30 +409,55 @@ export default class API { /** * Retrieve the authenticated user's search history - * @param lessonId the id to find the lesson + * @param skip number of entries skipped before returning + * @param take how much do we take to return + * @returns Returns an array of history entries (temporary type any) */ - public static async getSearchHistory(): Promise { - const tmp = await this.fetch({ - route: "/history/search", - }); + public static async getSearchHistory(skip?: number, take?: number): Promise { + return (await API.fetch({ + route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, + method: "GET", + })).map((e: any) => { + return { + id: e.id, + query: e.query, + type: e.type, + userId: e.userId, + timestamp: new Date(e.searchDate), + } as SearchHistory + }) + } - return tmp.map((value: any) => ({ - query: value.query, - userID: value.userId, - id: value.id, - })); + /** + * Posts a new entry in the user's search history + * @param query is the query itself + * @param type the type of object searched + * @param timestamp the date it's been issued + * @returns nothing + */ + public static async createSearchHistoryEntry(query: string, type: string, timestamp: number): Promise { + return await API.fetch({ + route: `/history/search`, + method: "POST", + body: { + query: query, + type: type + }, + }) } /** * Retrieve the authenticated user's recommendations + * @returns an array of songs */ - public static async getUserRecommendations(): Promise { + public static async getSongSuggestions(): Promise { const queryClient = new QueryClient(); return await queryClient.fetchQuery(["API", "allsongs"], API.getAllSongs); } /** * Retrieve the authenticated user's play history + * * @returns an array of songs */ public static async getUserPlayHistory(): Promise { return this.fetch({ diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 61ce96a..bc64775 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -18,6 +18,7 @@ import ScoreView from './views/ScoreView'; import { LoadingView } from './components/Loading'; import ProfileView from './views/ProfileView'; import useColorScheme from './hooks/colorScheme'; +import ArtistDetailsView from './views/ArtistDetailsView'; import { Button, Center, VStack } from 'native-base'; import { unsetAccessToken } from './state/UserSlice'; import TextButton from './components/TextButton'; @@ -28,6 +29,7 @@ const protectedRoutes = () => ({ Play: { component: PlayView, options: { title: translate('play') } }, Settings: { component: SetttingsNavigator, options: { title: 'Settings' } }, Song: { component: SongLobbyView, options: { title: translate('play') } }, + Artist: { component: ArtistDetailsView, options: { title: translate('artistFilter') } }, Score: { component: ScoreView, options: { title: translate('score'), headerLeft: null } }, Search: { component: SearchView, options: { title: translate('search') } }, User: { component: ProfileView, options: { title: translate('user') } }, diff --git a/front/components/ArtistCard.tsx b/front/components/ArtistCard.tsx new file mode 100644 index 0000000..fd10e61 --- /dev/null +++ b/front/components/ArtistCard.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import Card, { CardBorderRadius } from './Card'; +import { VStack, Text, Image } from 'native-base'; + +type ArtistCardProps = { + image: string; + name: string; + id: number; + onPress: () => void; +} + +const ArtistCard = (props: ArtistCardProps) => { + const { image, name, id } = props; + + return ( + + + {name} + + + {name} + + + + + ); +} + +ArtistCard.defaultProps = { + image: 'https://picsum.photos/200', + name: 'Artist', + id: 0, + onPress: () => { } +} + +export default ArtistCard; \ No newline at end of file diff --git a/front/components/CardGridCustom.tsx b/front/components/CardGridCustom.tsx new file mode 100644 index 0000000..41fc00d --- /dev/null +++ b/front/components/CardGridCustom.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { FlatGrid } from 'react-native-super-grid'; +import { Heading, VStack } from 'native-base'; + + +type CardGridCustomProps = { + content: T[]; + heading?: JSX.Element; + maxItemsPerRow?: number; + style?: Parameters[0]['additionalRowStyle']; + cardComponent: React.ComponentType; +}; + +const CardGridCustom = >(props: CardGridCustomProps) => { + const { content, heading, maxItemsPerRow, style, cardComponent: CardComponent } = props; + + return ( + + {heading && {heading}} + } + spacing={10} + /> + + ); +}; + +export default CardGridCustom; \ No newline at end of file diff --git a/front/components/GenreCard.tsx b/front/components/GenreCard.tsx new file mode 100644 index 0000000..40784a2 --- /dev/null +++ b/front/components/GenreCard.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import Card from './Card'; +import { VStack, Text, Box, Icon } from 'native-base'; +import { useTheme } from "native-base"; +import { Ionicons } from "@expo/vector-icons"; +import API from "../API"; +type GenreCardProps = { + icon: string; + name: string; + onPress: () => void; +} + +const GenreCard = (props: GenreCardProps) => { + const { icon, name } = props; + const theme = useTheme(); + + + return ( + + + + + + + + {name} + + + + + ); +} + +GenreCard.defaultProps = { + icon: 'https://picsum.photos/200', + name: 'Genre', + onPress: () => { } +} + +export default GenreCard; \ No newline at end of file diff --git a/front/components/HistoryCard.tsx b/front/components/HistoryCard.tsx new file mode 100644 index 0000000..21c6045 --- /dev/null +++ b/front/components/HistoryCard.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { VStack, Text } from 'native-base'; +import Card from './Card'; + +type SearchHistoryCardProps = { + query: string; + type: string; + timestamp?: string; +}; + +const SearchHistoryCard = (props: SearchHistoryCardProps & { onPress: (query: string) => void }) => { + const { query, type, timestamp, onPress } = props; + + const handlePress = () => { + if (onPress) { + onPress(query); + } + }; + + return ( + + + + {query ?? "query"} + + + {type ?? "type"} + + {timestamp ?? "timestamp"} + + + ); +}; + +export default SearchHistoryCard; \ No newline at end of file diff --git a/front/components/SearchBar.tsx b/front/components/SearchBar.tsx index 54dbf86..dc839b8 100644 --- a/front/components/SearchBar.tsx +++ b/front/components/SearchBar.tsx @@ -1,149 +1,119 @@ import { - Input, - Column, - Row, - Text, - Pressable, - HStack, - VStack, - Image, Icon, - Square, -} from "native-base"; + Input, + Button, + Flex} from "native-base"; import React from "react"; -import { Ionicons } from "@expo/vector-icons"; -import useColorScheme from "../hooks/colorScheme"; +import { MaterialIcons } from "@expo/vector-icons"; +import { translate } from "../i18n/i18n"; +import { SearchContext } from "../views/SearchView"; +import { debounce } from 'lodash'; -export enum SuggestionType { - TEXT, - ILLUSTRATED, -} +export type Filter = "artist" | "song" | "genre" | "all"; -export type SuggestionList = { - type: SuggestionType; - data: SuggestionProps | IllustratedSuggestionProps; -}[]; +type SearchBarProps = { + onChangeText?: any; +}; -export interface SearchBarProps { - onTextChange: (text: string) => void; - onTextSubmit: (text: string) => void; - suggestions: SuggestionList; -} -export interface IllustratedSuggestionProps { - text: string; - subtext: string; - imageSrc: string; - onPress: () => void; -} +type FilterButton = { + name: string; + callback: () => void; + id: Filter; +}; -export interface SuggestionProps { - text: string; - onPress: () => void; -} +const SearchBar = (props: SearchBarProps) => { + const {filter, updateFilter} = React.useContext(SearchContext); + const {stringQuery, updateStringQuery} = React.useContext(SearchContext); + const [barText, updateBarText] = React.useState(stringQuery); -// debounce function -const debounce = (func: any, delay: number) => { - let inDebounce: any; - return function (this: any) { - const context = this; - const args = arguments; - clearTimeout(inDebounce); - inDebounce = setTimeout(() => func.apply(context, args), delay); + const debouncedUpdateStringQuery = debounce(updateStringQuery, 500); + + // there's a bug due to recursive feedback that erase the text as soon as you type this is a temporary "fix" + // will probably be fixed by removing the React.useContext + // React.useEffect(() => { + // updateBarText(stringQuery); + // }, [stringQuery]); + + const handleClearQuery = () => { + updateStringQuery(''); + updateBarText(''); }; -}; -const IllustratedSuggestion = ({ - text, - subtext, - imageSrc, - onPress, -}: IllustratedSuggestionProps) => { - const colorScheme = useColorScheme(); + const handleChangeText = (text: string) => { + debouncedUpdateStringQuery(text); + updateBarText(text); + } + + const filters: FilterButton[] = [ + { + name: translate('allFilter'), + callback: () => updateFilter('all'), + id: 'all' + }, + { + name: translate('artistFilter'), + callback: () => updateFilter('artist'), + id: 'artist', + }, + { + name: translate('songsFilter'), + callback: () => updateFilter('song'), + id: 'song', + }, + { + name: translate('genreFilter'), + callback: () => updateFilter('genre'), + id: 'genre', + }, + ]; + return ( - {({ isHovered, isPressed }) => ( - - - Alternate Text - - - {text} - - {subtext} - - - - )} - ); -}; - -const TextSuggestion = ({ text, onPress }: SuggestionProps) => { - const colorScheme = useColorScheme(); - return ( - {({ isHovered, isPressed }) => ( - - - - - {text} - - )} - ); -}; - -// render the suggestions based on the type -const SuggestionRenderer = (suggestions: SuggestionList) => { - const suggestionRenderers = { - [SuggestionType.TEXT]: TextSuggestion, - [SuggestionType.ILLUSTRATED]: IllustratedSuggestion, - }; - return suggestions.map((suggestion, index) => { - const SuggestionComponent = suggestionRenderers[suggestion.type]; - return ; - }); -}; - -const SearchBar = ({ - onTextChange, - onTextSubmit, - suggestions, -}: SearchBarProps) => { - const debouncedOnTextChange = React.useRef( - debounce((t: string) => onTextChange(t), 70) - ).current; - return ( - <> + onTextSubmit(event.nativeEvent.text)} + onChangeText={(text) => handleChangeText(text)} + variant={"rounded"} + value={barText} + rounded={"full"} + placeholder={translate('search')} + width={['100%', '50%']} //responsive array syntax with native-base + py={2} + px={2} + fontSize={'12'} + InputLeftElement={ + } + /> + } + InputRightElement={} + />} /> - {SuggestionRenderer(suggestions)} - + + + {filters.map((btn) => ( + + ))} + + ); -}; +} export default SearchBar; \ No newline at end of file diff --git a/front/components/SearchBarSuggestions.tsx b/front/components/SearchBarSuggestions.tsx deleted file mode 100644 index 9818dd8..0000000 --- a/front/components/SearchBarSuggestions.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import SearchBar, { IllustratedSuggestionProps } from "../components/SearchBar"; -import { SuggestionList, SuggestionType } from "../components/SearchBar"; -interface SearchBarSuggestionsProps { - onTextSubmit: (text: string) => void; - suggestions: SuggestionList; -} - -// do a function that takes in a string and returns a list of filtered suggestions -const filterSuggestions = (text: string, suggestions: SuggestionList) => { - return suggestions.filter((suggestion) => { - switch (suggestion.type) { - case SuggestionType.TEXT: - return suggestion.data.text.toLowerCase().includes(text.toLowerCase()); - case SuggestionType.ILLUSTRATED: - return ( - suggestion.data.text.toLowerCase().includes(text.toLowerCase()) || - (suggestion.data as IllustratedSuggestionProps).subtext.toLowerCase().includes(text.toLowerCase()) - ); - } - }); -}; - -const SearchBarSuggestions = ({ - onTextSubmit, - suggestions, -}: SearchBarSuggestionsProps) => { - const [searchText, setSearchText] = React.useState(""); - - return ( - setSearchText(t)} - onTextSubmit={onTextSubmit} - suggestions={ - searchText === "" ? [] : filterSuggestions(searchText, suggestions) - } - /> - ); -}; - -export default SearchBarSuggestions; diff --git a/front/components/SearchResult.tsx b/front/components/SearchResult.tsx new file mode 100644 index 0000000..39481ff --- /dev/null +++ b/front/components/SearchResult.tsx @@ -0,0 +1,273 @@ +import React, { useContext, useEffect, useState } 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 { SearchContext } from "../views/SearchView"; +import { useQuery } from "react-query"; +import { translate } from "../i18n/i18n"; +import API from "../API"; +import LoadingComponent from "./Loading"; +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 { getSongWArtistSuggestions } from "./utils/api"; +import { useNavigation } from "../Navigation"; + +const swaToSongCardProps = (song: SongWithArtist) => ({ + songId: song.id, + name: song.name, + artistName: song.artist.name, + 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 {stringQuery, updateStringQuery} = React.useContext(SearchContext); + const {isLoading: isLoadingHistory, data: historyData = []} = useQuery( + 'history', + () => API.getSearchHistory(0, 12), + { enabled: true }, + ); + + const {isLoading: isLoadingSuggestions, data: suggestionsData = []} = useQuery( + 'suggestions', + () => getSongWArtistSuggestions(), + { enabled: true }, + ); + + return ( + + + {translate('lastSearched')} + { isLoadingHistory ? : { + return { + ...h, + timestamp: h.timestamp.toLocaleString(), + onPress: () => {updateStringQuery(h.query)} + } + })} cardComponent={SearchHistoryCard}/> } + + + {translate('songsToGetBetter')} + { isLoadingSuggestions ? : } + + + ); +} + +const SongsSearchComponent = (props: any) => { + const {songData} = React.useContext(SearchContext); + const navigation = useNavigation(); + + return ( + + + {translate('songsFilter')} + + + {songData?.length ? ( + songData.slice(0, props.maxRows).map((comp, index) => ( + { + API.createSearchHistoryEntry(comp.name, "song", Date.now()); + navigation.navigate('Song', { songId: comp.id }); + }} + /> + )) + ) : ( + {translate('errNoResults')} + )} + + + ); +} + +const ArtistSearchComponent = (props: any) => { + const {artistData} = React.useContext(SearchContext); + const navigation = useNavigation(); + + return ( + + + {translate('artistFilter')} + + { artistData?.length + ? ( + { + image: a.picture ?? 'https://picsum.photos/200', + name: a.name, + id: a.id, + onPress: () => { + API.createSearchHistoryEntry(a.name, "artist", Date.now()); + navigation.navigate('Artist', { artistId: a.id }) + } + } + ))} cardComponent={ArtistCard} /> + : {translate('errNoResults')} } + + ); +} + +const GenreSearchComponent = (props: any) => { + const {genreData} = React.useContext(SearchContext); + const navigation = useNavigation(); + + return ( + + + {translate('genreFilter')} + + { genreData?.length + ? ( + { + icon: 'musical-note-sharp', + name: g.name, + onPress: () => { + API.createSearchHistoryEntry(g.name, "genre", Date.now()); + navigation.navigate('Home'); + } + } + ))} cardComponent={GenreCard}/> + : {translate('errNoResults')} } + + ); +} + +const AllComponent = () => { + const screenSize = useBreakpointValue({ base: "small", md: "big" }); + const isMobileView = screenSize == "small"; + + return ( + + + + + + + + + + + + + + + + ); +} + +const FilterSwitch = () => { + const { filter } = React.useContext(SearchContext); + const [currentFilter, setCurrentFilter] = React.useState(filter); + + React.useEffect(() => { + setCurrentFilter(filter); + }, [filter]); + + switch (currentFilter) { + case "all": + return ; + case "song": + return ; + case "artist": + return ; + case "genre": + return ; + default: + return Something very bad happened: {currentFilter}; + } +}; + +export const SearchResultComponent = (props: any) => { + const [searchString, setSearchString] = useState(""); + const { stringQuery, updateStringQuery } = React.useContext(SearchContext); + const shouldOutput = !!stringQuery.trim(); + + return shouldOutput ? ( + + + + ) : ( + + ); +}; \ No newline at end of file diff --git a/front/components/SongCard.tsx b/front/components/SongCard.tsx index 46cc613..4296ac2 100644 --- a/front/components/SongCard.tsx +++ b/front/components/SongCard.tsx @@ -1,16 +1,16 @@ import React from "react"; import Card, { CardBorderRadius } from './Card'; -import { VStack, Text, Image, Pressable } from 'native-base'; +import { VStack, Text, Image } from 'native-base'; import { useNavigation } from "../Navigation"; type SongCardProps = { - albumCover: string; - songTitle: string; + cover: string; + name: string; artistName: string; songId: number } const SongCard = (props: SongCardProps) => { - const { albumCover, songTitle, artistName, songId } = props; + const { cover, name, artistName, songId } = props; const navigation = useNavigation(); return ( { {[props.songTitle, - {songTitle} + {name} {artistName} diff --git a/front/components/utils/api.tsx b/front/components/utils/api.tsx new file mode 100644 index 0000000..b5594a1 --- /dev/null +++ b/front/components/utils/api.tsx @@ -0,0 +1,15 @@ +import API from "../../API"; +import Song, { SongWithArtist } from "../../models/Song"; + +export const getSongWArtistSuggestions = async () => { + const nextStepQuery = await API.getSongSuggestions(); + + const songWartist = await Promise.all( + nextStepQuery.map(async (song) => { + if (!song.artistId) throw new Error("Song has no artistId"); + const artist = await API.getArtist(song.artistId); + return { ...song, artist } as SongWithArtist; + }) + ); + return songWartist; +}; diff --git a/front/i18n/Translations.ts b/front/i18n/Translations.ts index 68fd756..5e1d25d 100644 --- a/front/i18n/Translations.ts +++ b/front/i18n/Translations.ts @@ -34,6 +34,12 @@ export const en = { levelProgress: 'good notes', score: 'Score', + //search + allFilter: 'All', + artistFilter: 'Artists', + songsFilter: 'Songs', + genreFilter: 'Genres', + // profile page user: 'Profile', medals: 'Medals', @@ -97,6 +103,7 @@ export const en = { unknownError: 'Unknown error', errAlrdExst: 'Already exist', errIncrrct: 'Incorrect Credentials', + errNoResults: 'No Results Found', userProfileFetchError: 'An error occured while fetching your profile', tryAgain: 'Try Again', @@ -202,6 +209,12 @@ export const fr: typeof en = { longestCombo: 'Combo le plus long : ', favoriteGenre: 'Genre favori : ', + //search + allFilter: 'Tout', + artistFilter: 'Artistes', + songsFilter: 'Morceaux', + genreFilter: 'Genres', + // Difficulty settings diffBtn: 'Difficulté', easy: 'Débutant', @@ -273,6 +286,7 @@ export const fr: typeof en = { errAlrdExst: "Utilisateur existe déjà", unknownError: 'Erreur inconnue', errIncrrct: 'Identifiant incorrect', + errNoResults: 'Aucun resultat', userProfileFetchError: 'Une erreur est survenue lors de la récupération du profil', tryAgain: 'Réessayer', @@ -383,6 +397,12 @@ export const sp: typeof en = { longestCombo: 'combo más largo : ', favoriteGenre: 'genero favorito : ', + //search + allFilter: 'Todos', + artistFilter: 'Artistas', + songsFilter: 'canciones', + genreFilter: 'géneros', + // Difficulty settings diffBtn: 'Dificultad', easy: 'Principiante', @@ -435,6 +455,8 @@ export const sp: typeof en = { unknownError: 'Error desconocido', errAlrdExst: "Ya existe", errIncrrct: "credenciales incorrectas", + errNoResults: 'No se han encontrado resultados', + userProfileFetchError: 'Ocurrió un error al obtener su perfil', tryAgain: 'intentar otra vez', diff --git a/front/models/Album.ts b/front/models/Album.ts new file mode 100644 index 0000000..bda598c --- /dev/null +++ b/front/models/Album.ts @@ -0,0 +1,7 @@ +import Model from "./Model"; + +interface Album extends Model { + name: string; +} + +export default Album; \ No newline at end of file diff --git a/front/models/Artist.ts b/front/models/Artist.ts index 28b9610..af6a269 100644 --- a/front/models/Artist.ts +++ b/front/models/Artist.ts @@ -2,6 +2,7 @@ import Model from "./Model"; interface Artist extends Model { name: string; + picture?: string; } export default Artist; \ No newline at end of file diff --git a/front/models/SearchHistory.ts b/front/models/SearchHistory.ts index 53c162d..5384c66 100644 --- a/front/models/SearchHistory.ts +++ b/front/models/SearchHistory.ts @@ -1,7 +1,10 @@ -interface SearchHistory { - query: string; - userID: number; - id: number; +import Model from "./Model"; + +interface SearchHistory extends Model { + query: string; + type: "song" | "artist" | "album" | "genre"; + userId: number; + timestamp: Date; } export default SearchHistory; \ No newline at end of file diff --git a/front/models/Song.ts b/front/models/Song.ts index 3c590a0..f93e392 100644 --- a/front/models/Song.ts +++ b/front/models/Song.ts @@ -1,13 +1,19 @@ import Model from "./Model"; import SongDetails from "./SongDetails"; +import Artist from "./Artist"; interface Song extends Model { - name: string - artistId: number | null - albumId: number | null + id: number; + name: string; + artistId: number; + albumId: number | null; genreId: number | null; cover: string; details: SongDetails; } +export interface SongWithArtist extends Song { + artist: Artist; +} + export default Song; \ No newline at end of file diff --git a/front/views/ArtistDetailsView.tsx b/front/views/ArtistDetailsView.tsx new file mode 100644 index 0000000..4485263 --- /dev/null +++ b/front/views/ArtistDetailsView.tsx @@ -0,0 +1,42 @@ +import { VStack, Text, Image, Heading, IconButton, Icon, Container } from 'native-base'; +import { Ionicons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native'; +import { useQuery } from 'react-query'; +import LoadingComponent from '../components/Loading'; +import API from '../API'; + +const handleFavorite = () => { + +}; + +const ArtistDetailsView = ({ artistId }: any) => { + const { isLoading, data: artistData, error } = useQuery(['artist', artistId], () => API.getArtist(artistId)); + + if (isLoading) { + return ; + } + + return ( + + + {artistData?.name} + + {artistData?.name} + } + onPress={() => handleFavorite()} + variant="unstyled" + _pressed={{ opacity: 0.6 }} + /> + + + + ); +}; + +export default ArtistDetailsView; \ No newline at end of file diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index 7cc73c6..1c49767 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -1,6 +1,8 @@ import React from "react"; import { useQueries, useQuery } from "react-query"; import API from "../API"; +import LoadingComponent from "../components/Loading"; +import CardGridCustom from "../components/CardGridCustom"; import { LoadingView } from "../components/Loading"; import { Center, @@ -16,7 +18,7 @@ import { Column, Button, Text, - useTheme + useTheme } from "native-base"; import { useNavigation } from "../Navigation"; @@ -25,6 +27,7 @@ import CompetenciesTable from "../components/CompetenciesTable"; import ProgressBar from "../components/ProgressBar"; import Translate from "../components/Translate"; import TextButton from "../components/TextButton"; +import SearchHistoryCard from "../components/HistoryCard"; import Song from "../models/Song"; import { FontAwesome5 } from "@expo/vector-icons"; @@ -34,9 +37,9 @@ const HomeView = () => { const screenSize = useBreakpointValue({ base: 'small', md: "big"}); const userQuery = useQuery(['user'], () => API.getUserInfo()); const playHistoryQuery = useQuery(['history', 'play'], () => API.getUserPlayHistory()); - const searchHistoryQuery = useQuery(['history', 'search'], () => API.getSearchHistory()); + const searchHistoryQuery = useQuery(['history', 'search'], () => API.getSearchHistory(0, 10)); const skillsQuery = useQuery(['skills'], () => API.getUserSkills()); - const nextStepQuery = useQuery(['user', 'recommendations'], () => API.getUserRecommendations()); + const nextStepQuery = useQuery(['user', 'recommendations'], () => API.getSongSuggestions()); const songHistory = useQueries( playHistoryQuery.data?.map(({ songID }) => ({ queryKey: ['song', songID], @@ -48,7 +51,7 @@ const HomeView = () => { .concat(nextStepQuery.data ?? []) .filter((s): s is Song => s !== undefined)) .map((song) => ( - { queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.id) } + { queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.artistId) } )) ); @@ -77,8 +80,8 @@ const HomeView = () => { heading={} songs={nextStepQuery.data?.filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)) .map((song) => ({ - albumCover: song.cover, - songTitle: song.name, + cover: song.cover, + name: song.name, songId: song.id, artistName: artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)!.data!.name })) ?? [] @@ -100,9 +103,9 @@ const HomeView = () => { .filter((song, i, array) => array.map((s) => s.id).findIndex((id) => id == song.id) == i) .filter((song) => artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)) .map((song) => ({ - albumCover: song.cover, - songTitle: song.name, - songId: song.id, + cover: song.cover, + name: song.name, + id: song.id, artistName: artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)!.data!.name })) ?? [] } @@ -135,7 +138,7 @@ const HomeView = () => { searchHistoryQuery.data?.length === 0 && } { - [...(new Set(searchHistoryQuery.data.map((x) => x.query)))].reverse().slice(0, 5).map((query) => ( + [...(new Set(searchHistoryQuery.data.map((x) => x.query)))].slice(0, 5).map((query) => (