Feature/adc/#192 barre de recherche (#201)
* context and react query add to searchView * handle empty result + back * #192 - New card components and history fetch + dummy suggestions fetch * respoonsive design + filters map * artist details view + translations + SongData mapping fix + items limitation * history search back and front + cards + fix * fixed useless history entries * clean code * clean code * fix pr: SearchHistory new type related fixes * simplified SearchResultComponent (useEffect removed, condition simplified to trigger different 'modes' * search re-do onPress history cards + scoreView obj map * clean code API.ts * fix pr + search history behavior * added utility function to get song suggestions with artists and fixed error types along the way * fix in songrow the title didn't shrinked when not enough space on screen * removed redirect callback from ArtistCard to ArtistResults * moved the callback from genre card grid to searchresult and implemented history for songs * SearchBar is now updating input search following stringQuery * added scroll view to have the complete background * Added the route props for query in Searchview * fixed robot test --------- Co-authored-by: Clément Le Bihan <clement.lebihan773@gmail.com>
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<SongHistory> {
|
||||
return this.historyService.createSongHistoryRecord(record);
|
||||
}
|
||||
|
||||
@Post("search")
|
||||
@HttpCode(201)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({description: "Invalid token"})
|
||||
async createSearchHistory(
|
||||
@Request() req: any,
|
||||
@Body() record: SearchHistoryDto
|
||||
): Promise<void> {
|
||||
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,11 +72,10 @@ export class HistoryService {
|
||||
};
|
||||
}
|
||||
|
||||
async createSearchHistoryRecord({
|
||||
userID,
|
||||
query,
|
||||
type,
|
||||
}: SearchHistoryDto): Promise<SearchHistory> {
|
||||
async createSearchHistoryRecord(
|
||||
userID: number,
|
||||
{ query, type }: SearchHistoryDto
|
||||
): Promise<SearchHistory> {
|
||||
return this.prisma.searchHistory.create({
|
||||
data: {
|
||||
query,
|
||||
|
||||
@@ -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<Song | null> {
|
||||
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<Song[] | null> {
|
||||
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
|
||||
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<Song[] | null> {
|
||||
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<Song[] | null> {
|
||||
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<Song[] | null> {
|
||||
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<any[] | null> {
|
||||
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
|
||||
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<Artist[] | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Song | null> {
|
||||
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<Song[]> {
|
||||
async songByGuess(query: string, userID: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
artistId: artistId,
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
orderBy: [],
|
||||
});
|
||||
}
|
||||
|
||||
async songsByGenre(genreId: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
|
||||
return this.prisma.genre.findMany({
|
||||
where: {
|
||||
genreId: genreId,
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async songsByAlbum(albumId: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
albumId: albumId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async artistByName(artistName: string): Promise<Artist | null> {
|
||||
return this.prisma.artist.findUnique({
|
||||
where: {
|
||||
name: artistName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async guessSong(word: string, userID: number): Promise<Song[]> {
|
||||
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<Artist[]> {
|
||||
await this.history.createSearchHistoryRecord({ query: word, type: "artist", userID });
|
||||
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
|
||||
return this.prisma.artist.findMany({
|
||||
where: {
|
||||
name: { contains: word },
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async guessAlbum(word: string, userID: number): Promise<Album[]> {
|
||||
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<Song[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
97
front/API.ts
97
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<Song[]> {
|
||||
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<Artist[]> {
|
||||
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<Album[]> {
|
||||
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<Genre[]> {
|
||||
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<SearchHistory[]> {
|
||||
const tmp = await this.fetch({
|
||||
route: "/history/search",
|
||||
});
|
||||
public static async getSearchHistory(skip?: number, take?: number): Promise<SearchHistory[]> {
|
||||
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<void> {
|
||||
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<Song[]> {
|
||||
public static async getSongSuggestions(): Promise<Song[]> {
|
||||
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<SongHistory[]> {
|
||||
return this.fetch({
|
||||
|
||||
@@ -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') } },
|
||||
|
||||
43
front/components/ArtistCard.tsx
Normal file
43
front/components/ArtistCard.tsx
Normal file
@@ -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 (
|
||||
<Card
|
||||
shadow={3}
|
||||
onPress={props.onPress}
|
||||
>
|
||||
<VStack m={1.5} space={3}>
|
||||
<Image
|
||||
style={{ zIndex: 0, aspectRatio: 1, borderRadius: CardBorderRadius }}
|
||||
source={{ uri: image }}
|
||||
alt={name}
|
||||
/>
|
||||
<VStack>
|
||||
<Text isTruncated bold fontSize="md" noOfLines={2} height={50}>
|
||||
{name}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ArtistCard.defaultProps = {
|
||||
image: 'https://picsum.photos/200',
|
||||
name: 'Artist',
|
||||
id: 0,
|
||||
onPress: () => { }
|
||||
}
|
||||
|
||||
export default ArtistCard;
|
||||
31
front/components/CardGridCustom.tsx
Normal file
31
front/components/CardGridCustom.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { FlatGrid } from 'react-native-super-grid';
|
||||
import { Heading, VStack } from 'native-base';
|
||||
|
||||
|
||||
type CardGridCustomProps<T> = {
|
||||
content: T[];
|
||||
heading?: JSX.Element;
|
||||
maxItemsPerRow?: number;
|
||||
style?: Parameters<typeof FlatGrid>[0]['additionalRowStyle'];
|
||||
cardComponent: React.ComponentType<T>;
|
||||
};
|
||||
|
||||
const CardGridCustom = <T extends Record<string, any>>(props: CardGridCustomProps<T>) => {
|
||||
const { content, heading, maxItemsPerRow, style, cardComponent: CardComponent } = props;
|
||||
|
||||
return (
|
||||
<VStack space={5}>
|
||||
{heading && <Heading>{heading}</Heading>}
|
||||
<FlatGrid
|
||||
maxItemsPerRow={maxItemsPerRow}
|
||||
additionalRowStyle={style ?? { justifyContent: 'flex-start' }}
|
||||
data={content}
|
||||
renderItem={({ item }) => <CardComponent {...item} />}
|
||||
spacing={10}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardGridCustom;
|
||||
51
front/components/GenreCard.tsx
Normal file
51
front/components/GenreCard.tsx
Normal file
@@ -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 (
|
||||
<Card
|
||||
shadow={3}
|
||||
onPress={props.onPress}
|
||||
>
|
||||
<VStack m={1.5} space={3} alignItems="center">
|
||||
<Box
|
||||
bg={theme.colors.primary[400]}
|
||||
w={20}
|
||||
h={20}
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon size={"md"} as={Ionicons} name={icon} />
|
||||
</Box>
|
||||
<VStack>
|
||||
<Text isTruncated bold fontSize="md" noOfLines={2} height={50}>
|
||||
{name}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
GenreCard.defaultProps = {
|
||||
icon: 'https://picsum.photos/200',
|
||||
name: 'Genre',
|
||||
onPress: () => { }
|
||||
}
|
||||
|
||||
export default GenreCard;
|
||||
35
front/components/HistoryCard.tsx
Normal file
35
front/components/HistoryCard.tsx
Normal file
@@ -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 (
|
||||
<Card shadow={2} onPress={handlePress} >
|
||||
<VStack m={1.5} space={3}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{query ?? "query"}
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{type ?? "type"}
|
||||
</Text>
|
||||
<Text color="gray.500">{timestamp ?? "timestamp"}</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchHistoryCard;
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
margin={2}
|
||||
padding={2}
|
||||
>{({ isHovered, isPressed }) => (
|
||||
<HStack alignItems="center" space={4}
|
||||
bg={colorScheme == 'dark'
|
||||
? (isHovered || isPressed) ? 'gray.800' : undefined
|
||||
: (isHovered || isPressed) ? 'primary.100' : undefined
|
||||
}
|
||||
>
|
||||
<Square size={"sm"}>
|
||||
<Image
|
||||
source={{ uri: imageSrc }}
|
||||
alt="Alternate Text"
|
||||
size="xs"
|
||||
rounded="lg"
|
||||
/>
|
||||
</Square>
|
||||
<VStack alignItems="flex-start">
|
||||
<Text fontSize="md">{text}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{subtext}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TextSuggestion = ({ text, onPress }: SuggestionProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
margin={2}
|
||||
padding={2}
|
||||
>{({ isHovered, isPressed }) => (
|
||||
<Row alignItems="center" space={4}
|
||||
bg={colorScheme == 'dark'
|
||||
? (isHovered || isPressed) ? 'gray.800' : undefined
|
||||
: (isHovered || isPressed) ? 'primary.100' : undefined
|
||||
}
|
||||
>
|
||||
<Square size={"sm"}>
|
||||
<Icon size={"md"} as={Ionicons} name="search" />
|
||||
</Square>
|
||||
<Text fontSize="md">{text}</Text>
|
||||
</Row>
|
||||
)}</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 <SuggestionComponent {...suggestion.data} key={index} />;
|
||||
});
|
||||
};
|
||||
|
||||
const SearchBar = ({
|
||||
onTextChange,
|
||||
onTextSubmit,
|
||||
suggestions,
|
||||
}: SearchBarProps) => {
|
||||
const debouncedOnTextChange = React.useRef(
|
||||
debounce((t: string) => onTextChange(t), 70)
|
||||
).current;
|
||||
return (
|
||||
<>
|
||||
<Flex m={3} flexDirection={["column", "row"]}>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
type="text"
|
||||
onChangeText={debouncedOnTextChange}
|
||||
onSubmitEditing={(event) => 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={
|
||||
<Icon
|
||||
m={[1, 2]}
|
||||
ml={[2, 3]}
|
||||
size={['4', '6']}
|
||||
color="gray.400"
|
||||
as={<MaterialIcons name="search" />}
|
||||
/>
|
||||
}
|
||||
InputRightElement={<Icon
|
||||
m={[1, 2]}
|
||||
mr={[2, 3]}
|
||||
size={['4', '6']}
|
||||
color="gray.400"
|
||||
onPress={handleClearQuery}
|
||||
as={<MaterialIcons name="close" />}
|
||||
/>}
|
||||
/>
|
||||
<Column>{SuggestionRenderer(suggestions)}</Column>
|
||||
</>
|
||||
|
||||
<Flex flexDirection={'row'} >
|
||||
{filters.map((btn) => (
|
||||
<Button
|
||||
key={btn.name}
|
||||
rounded={'full'}
|
||||
onPress={btn.callback}
|
||||
mx={[2, 5]}
|
||||
my={[1, 0]}
|
||||
minW={[30, 20]}
|
||||
variant={filter === btn.id ? 'solid' : 'outline'}>
|
||||
{btn.name}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
@@ -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 (
|
||||
<SearchBar
|
||||
onTextChange={(t) => setSearchText(t)}
|
||||
onTextSubmit={onTextSubmit}
|
||||
suggestions={
|
||||
searchText === "" ? [] : filterSuggestions(searchText, suggestions)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBarSuggestions;
|
||||
273
front/components/SearchResult.tsx
Normal file
273
front/components/SearchResult.tsx
Normal file
@@ -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<typeof Box>[0] & { onPress?: () => void }) => {
|
||||
const settings = useSelector((state: RootState) => state.settings.local);
|
||||
const systemColorMode = useColorScheme();
|
||||
const colorScheme = settings.colorScheme;
|
||||
|
||||
return <Pressable onPress={props.onPress}>
|
||||
{({ isHovered, isPressed }) => (
|
||||
<Box {...props}
|
||||
py={3}
|
||||
my={1}
|
||||
bg={(colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark'
|
||||
? (isHovered || isPressed) ? 'gray.800' : undefined
|
||||
: (isHovered || isPressed) ? 'coolGray.200' : undefined
|
||||
}
|
||||
>
|
||||
{ props.children }
|
||||
</Box>
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
|
||||
type SongRowProps = {
|
||||
song: Song | SongWithArtist; // TODO: remove Song
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const SongRow = ({ song, onPress }: SongRowProps) => {
|
||||
|
||||
return (
|
||||
<RowCustom width={"100%"}>
|
||||
<HStack px={2} space={5} justifyContent={"space-between"} >
|
||||
<Image
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
pl={10}
|
||||
style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }}
|
||||
source={{ uri: song.cover ?? 'https://picsum.photos/200' }}
|
||||
alt={song.name}
|
||||
/>
|
||||
<HStack style={{display: 'flex', flexShrink: 1, flexGrow: 1, alignItems: 'center', justifyContent: "flex-start"}} space={6}>
|
||||
<Text style={{
|
||||
flexShrink: 1,
|
||||
}} isTruncated pl={10} maxW={"100%"} bold fontSize='md'>{song.name}</Text>
|
||||
<Text style={{
|
||||
flexShrink: 0,
|
||||
}} fontSize={"sm"}>{song.artistId ?? 'artist'}</Text>
|
||||
</HStack>
|
||||
<TextButton
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
translate={{ translationKey: 'playBtn' }}
|
||||
colorScheme='primary' variant={"outline"} size='sm'
|
||||
onPress={onPress}
|
||||
/>
|
||||
</HStack>
|
||||
</RowCustom>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<VStack mt="5" style={{overflow: 'hidden'}}>
|
||||
<Card shadow={3} mb={5}>
|
||||
<Heading margin={5}>{translate('lastSearched')}</Heading>
|
||||
{ isLoadingHistory ? <LoadingComponent/> : <CardGridCustom content={historyData.map((h) => {
|
||||
return {
|
||||
...h,
|
||||
timestamp: h.timestamp.toLocaleString(),
|
||||
onPress: () => {updateStringQuery(h.query)}
|
||||
}
|
||||
})} cardComponent={SearchHistoryCard}/> }
|
||||
</Card>
|
||||
<Card shadow={3} mt={5} mb={5}>
|
||||
<Heading margin={5}>{translate('songsToGetBetter')}</Heading>
|
||||
{ isLoadingSuggestions ? <LoadingComponent/> : <CardGridCustom content={suggestionsData.map(swaToSongCardProps)} cardComponent={SongCard}/> }
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const SongsSearchComponent = (props: any) => {
|
||||
const {songData} = React.useContext(SearchContext);
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<Text fontSize="xl" fontWeight="bold" mt={4}>
|
||||
{translate('songsFilter')}
|
||||
</Text>
|
||||
<Box>
|
||||
{songData?.length ? (
|
||||
songData.slice(0, props.maxRows).map((comp, index) => (
|
||||
<SongRow
|
||||
key={index}
|
||||
song={comp}
|
||||
onPress={() => {
|
||||
API.createSearchHistoryEntry(comp.name, "song", Date.now());
|
||||
navigation.navigate('Song', { songId: comp.id });
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text>{translate('errNoResults')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const ArtistSearchComponent = (props: any) => {
|
||||
const {artistData} = React.useContext(SearchContext);
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" mt={4}>
|
||||
{translate('artistFilter')}
|
||||
</Text>
|
||||
{ artistData?.length
|
||||
? <CardGridCustom content={artistData.slice(0, props?.maxItems ?? artistData.length).map((a) => (
|
||||
{
|
||||
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} />
|
||||
: <Text>{translate('errNoResults')}</Text> }
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const GenreSearchComponent = (props: any) => {
|
||||
const {genreData} = React.useContext(SearchContext);
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" mt={4}>
|
||||
{translate('genreFilter')}
|
||||
</Text>
|
||||
{ genreData?.length
|
||||
? <CardGridCustom content={genreData.slice(0, props?.maxItems ?? genreData.length).map((g) => (
|
||||
{
|
||||
icon: 'musical-note-sharp',
|
||||
name: g.name,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(g.name, "genre", Date.now());
|
||||
navigation.navigate('Home');
|
||||
}
|
||||
}
|
||||
))} cardComponent={GenreCard}/>
|
||||
: <Text>{translate('errNoResults')}</Text> }
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const AllComponent = () => {
|
||||
const screenSize = useBreakpointValue({ base: "small", md: "big" });
|
||||
const isMobileView = screenSize == "small";
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<Flex flexWrap="wrap" direction={isMobileView ? 'column' : 'row'} justifyContent={['flex-start']} mt={4}>
|
||||
<Column w={isMobileView ? '100%' : '50%'}>
|
||||
<Box minH={isMobileView ? 100 : 200}>
|
||||
<ArtistSearchComponent maxItems={6}/>
|
||||
</Box>
|
||||
<Box minH={isMobileView ? 100 : 200}>
|
||||
<GenreSearchComponent maxItems={6}/>
|
||||
</Box>
|
||||
</Column>
|
||||
<Box w={isMobileView ? '100%' : '50%'}>
|
||||
<SongsSearchComponent maxRows={9}/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterSwitch = () => {
|
||||
const { filter } = React.useContext(SearchContext);
|
||||
const [currentFilter, setCurrentFilter] = React.useState(filter);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentFilter(filter);
|
||||
}, [filter]);
|
||||
|
||||
switch (currentFilter) {
|
||||
case "all":
|
||||
return <AllComponent />;
|
||||
case "song":
|
||||
return <SongsSearchComponent />;
|
||||
case "artist":
|
||||
return <ArtistSearchComponent />;
|
||||
case "genre":
|
||||
return <GenreSearchComponent />;
|
||||
default:
|
||||
return <Text>Something very bad happened: {currentFilter}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchResultComponent = (props: any) => {
|
||||
const [searchString, setSearchString] = useState<string>("");
|
||||
const { stringQuery, updateStringQuery } = React.useContext(SearchContext);
|
||||
const shouldOutput = !!stringQuery.trim();
|
||||
|
||||
return shouldOutput ? (
|
||||
<Box p={5}>
|
||||
<FilterSwitch />
|
||||
</Box>
|
||||
) : (
|
||||
<HomeSearchComponent />
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Card
|
||||
@@ -20,12 +20,12 @@ const SongCard = (props: SongCardProps) => {
|
||||
<VStack m={1.5} space={3}>
|
||||
<Image
|
||||
style={{ zIndex: 0, aspectRatio: 1, borderRadius: CardBorderRadius}}
|
||||
source={{ uri: albumCover }}
|
||||
alt={[props.songTitle, props.artistName].join('-')}
|
||||
source={{ uri: cover }}
|
||||
alt={[props.name, props.artistName].join('-')}
|
||||
/>
|
||||
<VStack>
|
||||
<Text isTruncated bold fontSize='md' noOfLines={2} height={50}>
|
||||
{songTitle}
|
||||
{name}
|
||||
</Text>
|
||||
<Text isTruncated >
|
||||
{artistName}
|
||||
|
||||
15
front/components/utils/api.tsx
Normal file
15
front/components/utils/api.tsx
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
|
||||
7
front/models/Album.ts
Normal file
7
front/models/Album.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Model from "./Model";
|
||||
|
||||
interface Album extends Model {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default Album;
|
||||
@@ -2,6 +2,7 @@ import Model from "./Model";
|
||||
|
||||
interface Artist extends Model {
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export default Artist;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
42
front/views/ArtistDetailsView.tsx
Normal file
42
front/views/ArtistDetailsView.tsx
Normal file
@@ -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 <LoadingComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<Container m={3}>
|
||||
<Image
|
||||
source={{ uri: 'https://picsum.photos/200' }}
|
||||
alt={artistData?.name}
|
||||
size={20}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<VStack space={3}>
|
||||
<Heading>{artistData?.name}</Heading>
|
||||
<IconButton
|
||||
icon={<Icon as={Ionicons} name="heart" size={6} color="red.500" />}
|
||||
onPress={() => handleFavorite()}
|
||||
variant="unstyled"
|
||||
_pressed={{ opacity: 0.6 }}
|
||||
/>
|
||||
</VStack>
|
||||
</Container>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtistDetailsView;
|
||||
@@ -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={<Translate translationKey='goNextStep'/>}
|
||||
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 && <Translate translationKey='noRecentSearches'/>
|
||||
}
|
||||
{
|
||||
[...(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) => (
|
||||
<Button
|
||||
leftIcon={
|
||||
<FontAwesome5 name="search" size={16} />
|
||||
|
||||
@@ -5,6 +5,9 @@ import { RouteProps, useNavigation } from "../Navigation";
|
||||
import { CardBorderRadius } from "../components/Card";
|
||||
import TextButton from "../components/TextButton";
|
||||
import API from '../API';
|
||||
import LoadingComponent from "../components/Loading";
|
||||
import CardGridCustom from "../components/CardGridCustom";
|
||||
import SongCard from "../components/SongCard";
|
||||
import { useQueries, useQuery } from "react-query";
|
||||
import { LoadingView } from "../components/Loading";
|
||||
|
||||
@@ -28,7 +31,7 @@ const ScoreView = ({ songId, overallScore, score }: RouteProps<ScoreViewProps>)
|
||||
{ enabled: songQuery.data != undefined }
|
||||
);
|
||||
// const perfoamnceRecommandationsQuery = useQuery(['song', props.songId, 'score', 'latest', 'recommendations'], () => API.getLastSongPerformanceScore(props.songId));
|
||||
const recommendations = useQuery(['song', 'recommendations'], () => API.getUserRecommendations());
|
||||
const recommendations = useQuery(['song', 'recommendations'], () => API.getSongSuggestions());
|
||||
const artistRecommendations = useQueries(recommendations.data
|
||||
?.filter(({ artistId }) => artistId !== null)
|
||||
.map((song) => ({
|
||||
@@ -75,17 +78,18 @@ const ScoreView = ({ songId, overallScore, score }: RouteProps<ScoreViewProps>)
|
||||
</Column>
|
||||
</Card>
|
||||
</Row>
|
||||
<SongCardGrid
|
||||
<CardGridCustom
|
||||
style={{ justifyContent: "space-evenly" }}
|
||||
content={recommendations.data.map((i) => ({
|
||||
cover: i.cover,
|
||||
name: i.name ,
|
||||
artistName: artistRecommendations.find(({ data }) => data?.id == i.artistId)?.data?.name ?? "",
|
||||
id: i.id
|
||||
}))}
|
||||
cardComponent={SongCard}
|
||||
heading={<Text fontSize='sm'>
|
||||
<Translate translationKey="songsToGetBetter"/>
|
||||
</Text>}
|
||||
songs={recommendations.data.map((i) => ({
|
||||
albumCover: i.cover,
|
||||
songTitle: i.name ,
|
||||
artistName: artistRecommendations.find(({ data }) => data?.id == i.artistId)?.data?.name ?? "",
|
||||
songId: i.id
|
||||
}))}
|
||||
/>
|
||||
<Row space={3} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<TextButton colorScheme='gray'
|
||||
|
||||
@@ -1,38 +1,102 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "native-base";
|
||||
import { useNavigation } from "../Navigation";
|
||||
import SearchBarSuggestions from "../components/SearchBarSuggestions";
|
||||
import { useQueries, useQuery } from "react-query";
|
||||
import { SuggestionType } from "../components/SearchBar";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import Artist from "../models/Artist";
|
||||
import Song from "../models/Song";
|
||||
import Genre from "../models/Genre";
|
||||
import API from "../API";
|
||||
import { useQuery } from "react-query";
|
||||
import { SearchResultComponent } from "../components/SearchResult";
|
||||
import { SafeAreaView } from "react-native";
|
||||
import { Filter } from "../components/SearchBar";
|
||||
import { ScrollView } from "native-base";
|
||||
import { RouteProps } from "../Navigation";
|
||||
|
||||
const SearchView = () => {
|
||||
const [query, setQuery] = useState<string>();
|
||||
const navigation = useNavigation();
|
||||
const searchQuery = useQuery(
|
||||
['search', query],
|
||||
() => API.searchSongs(query!),
|
||||
{ enabled: query != undefined }
|
||||
interface SearchContextType {
|
||||
filter: "artist" | "song" | "genre" | "all";
|
||||
updateFilter: (newData: "artist" | "song" | "genre" | "all") => void;
|
||||
stringQuery: string;
|
||||
updateStringQuery: (newData: string) => void;
|
||||
songData: Song[];
|
||||
artistData: Artist[];
|
||||
genreData: Genre[];
|
||||
isLoadingSong: boolean;
|
||||
isLoadingArtist: boolean;
|
||||
isLoadingGenre: boolean;
|
||||
}
|
||||
|
||||
export const SearchContext = React.createContext<SearchContextType>({
|
||||
filter: "all",
|
||||
updateFilter: () => {},
|
||||
stringQuery: "",
|
||||
updateStringQuery: () => {},
|
||||
songData: [],
|
||||
artistData: [],
|
||||
genreData: [],
|
||||
isLoadingSong: false,
|
||||
isLoadingArtist: false,
|
||||
isLoadingGenre: false,
|
||||
});
|
||||
|
||||
type SearchViewProps = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
const SearchView = (props: RouteProps<SearchViewProps>) => {
|
||||
let isRequestSucceeded = false;
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [stringQuery, setStringQuery] = useState<string>(props.query || "");
|
||||
|
||||
const { isLoading: isLoadingSong, data: songData = [] } = useQuery(
|
||||
["song", stringQuery],
|
||||
() => API.searchSongs(stringQuery),
|
||||
{ enabled: !!stringQuery }
|
||||
);
|
||||
const artistsQueries = useQueries(searchQuery.data?.map((song) => (
|
||||
{ queryKey: ['artist', song.id], queryFn: () => API.getArtist(song.id) }
|
||||
)) ??[]);
|
||||
|
||||
const { isLoading: isLoadingArtist, data: artistData = [] } = useQuery(
|
||||
["artist", stringQuery],
|
||||
() => API.searchArtists(stringQuery),
|
||||
{ enabled: !!stringQuery }
|
||||
);
|
||||
|
||||
const { isLoading: isLoadingGenre, data: genreData = [] } = useQuery(
|
||||
["genre", stringQuery],
|
||||
() => API.searchGenres(stringQuery),
|
||||
{ enabled: !!stringQuery }
|
||||
);
|
||||
|
||||
const updateFilter = (newData: Filter) => {
|
||||
// called when the filter is changed
|
||||
setFilter(newData);
|
||||
};
|
||||
|
||||
const updateStringQuery = (newData: string) => {
|
||||
// called when the stringQuery is updated
|
||||
setStringQuery(newData);
|
||||
isRequestSucceeded = false;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<SearchBarSuggestions
|
||||
onTextSubmit={setQuery}
|
||||
suggestions={searchQuery.data?.map((searchResult) => ({
|
||||
type: SuggestionType.ILLUSTRATED,
|
||||
data: {
|
||||
text: searchResult.name,
|
||||
subtext: artistsQueries.find((artistQuery) => artistQuery.data?.id == searchResult.artistId)?.data?.name ?? "",
|
||||
imageSrc: searchResult.cover,
|
||||
onPress: () => navigation.navigate("Song", { songId: searchResult.id })
|
||||
}
|
||||
})) ?? []}
|
||||
/>
|
||||
</Box>
|
||||
<ScrollView>
|
||||
<SafeAreaView>
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
filter,
|
||||
stringQuery,
|
||||
songData,
|
||||
artistData,
|
||||
genreData,
|
||||
isLoadingSong,
|
||||
isLoadingArtist,
|
||||
isLoadingGenre,
|
||||
updateFilter,
|
||||
updateStringQuery,
|
||||
}}
|
||||
>
|
||||
<SearchBar />
|
||||
<SearchResultComponent />
|
||||
</SearchContext.Provider>
|
||||
</SafeAreaView>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user