Merge pull request #358 from Chroma-Case/feat/adc/search-view-v2

Feat/adc/search view v2
This commit is contained in:
Clément Le Bihan
2024-01-14 18:26:32 +01:00
committed by GitHub
11 changed files with 10875 additions and 621 deletions

View File

@@ -1,3 +1,3 @@
FROM node:17
FROM node:18.10.0
WORKDIR /app
CMD npm i ; npx prisma generate ; npx prisma migrate dev ; npm run start:dev

10761
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Put } from "@nestjs/common";
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
import { ScoresService } from "./scores.service";
import { User } from "@prisma/client";
@@ -13,4 +13,10 @@ export class ScoresController {
getTopTwenty(): Promise<User[]> {
return this.scoresService.topTwenty();
}
// @ApiOkResponse{{description: "Successfully updated the user's total score"}}
// @Put("/add")
// addScore(): Promise<void> {
// return this.ScoresService.add()
// }
}

View File

@@ -1,5 +1,4 @@
import Artist, { ArtistHandler } from './models/Artist';
import Album from './models/Album';
import Chapter from './models/Chapter';
import Lesson from './models/Lesson';
import Genre, { GenreHandler } from './models/Genre';
@@ -24,6 +23,7 @@ import * as yup from 'yup';
import { base64ToBlob } from './utils/base64ToBlob';
import { ImagePickerAsset } from 'expo-image-picker';
import { SongCursorInfos, SongCursorInfosHandler } from './models/SongCursorInfos';
import { searchProps } from './views/V2/SearchView';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -497,84 +497,6 @@ export default class API {
};
}
/**
* Search a song by its name
* @param query the string used to find the songs
*/
public static searchSongs(query: string): Query<Song[]> {
return {
key: ['search', 'song', query],
exec: () =>
API.fetch(
{
route: `/search/songs/${query}`,
},
{ handler: ListHandler(SongHandler) }
),
};
}
/**
* Search artists by name
* @param query the string used to find the artists
*/
public static searchArtists(query: string): Query<Artist[]> {
return {
key: ['search', 'artist', query],
exec: () =>
API.fetch(
{
route: `/search/artists/${query}`,
},
{ handler: ListHandler(ArtistHandler) }
),
};
}
/**
* Search Album by name
* @param query the string used to find the album
*/
public static searchAlbum(query: string): Query<Album[]> {
return {
key: ['search', 'album', query],
exec: async () => [
{
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',
},
],
};
}
/**
* Retrieve music genres
*/
public static searchGenres(query: string): Query<Genre[]> {
return {
key: ['search', 'genre', query],
exec: () =>
API.fetch(
{
route: `/search/genres/${query}`,
},
{ handler: ListHandler(GenreHandler) }
),
};
}
/**
* Retrieve a lesson
* @param lessonId the id to find the lesson
@@ -780,6 +702,29 @@ export default class API {
return `${API.baseUrl}/song/${songId}/assets/partition`;
}
public static searchSongs(query: searchProps, include?: SongInclude[]): Query<Song[]> {
const queryParams: string[] = [];
if (query.query) queryParams.push(`q=${encodeURIComponent(query.query)}`);
if (query.artist) queryParams.push(`artistId=${query.artist}`);
if (query.genre) queryParams.push(`genreId=${query.genre}`);
if (include) queryParams.push(`include=${include.join(',')}`);
const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
return {
key: ['search', query.query, query.artist, query.genre, include],
exec: () => {
return API.fetch(
{
route: `/search/songs${queryString}`,
},
{ handler: ListHandler(SongHandler) }
);
},
};
}
public static getPartitionMelodyUrl(songId: number): string {
return `${API.baseUrl}/song/${songId}/assets/melody`;
}

View File

@@ -121,7 +121,7 @@ const Graph = ({ songId, since }: GraphProps) => {
const ScoreGraph = () => {
const layout = useWindowDimensions();
const songs = useQuery(API.getAllSongs);
const songs = useQuery(API.getAllSongs());
const rangeOptions = [
{ label: '3 derniers jours', value: '3days' },
{ label: 'Dernière semaine', value: 'week' },

View File

@@ -1,119 +0,0 @@
import { Icon, Input, Button, Flex } from 'native-base';
import React from 'react';
import { MaterialIcons } from '@expo/vector-icons';
import { translate } from '../i18n/i18n';
import { SearchContext } from '../views/SearchView';
import { debounce } from 'lodash';
export type Filter = 'artist' | 'song' | 'genre' | 'all' | 'favorites';
type FilterButton = {
name: string;
callback: () => void;
id: Filter;
};
const SearchBar = () => {
const { filter, updateFilter } = React.useContext(SearchContext);
const { stringQuery, updateStringQuery } = React.useContext(SearchContext);
const [barText, updateBarText] = React.useState(stringQuery);
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 handleChangeText = (text: string) => {
debouncedUpdateStringQuery(text);
updateBarText(text);
};
const filters: FilterButton[] = [
{
name: translate('allFilter'),
callback: () => updateFilter('all'),
id: 'all',
},
{
name: translate('favoriteFilter'),
callback: () => updateFilter('favorites'),
id: 'favorites',
},
{
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 (
<Flex m={3} flexDirection={['column', 'row']}>
<Input
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" />}
/>
}
/>
<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;

View File

@@ -1,277 +0,0 @@
import React from 'react';
import {
VStack,
Heading,
Box,
Card,
Flex,
useBreakpointValue,
Column,
ScrollView,
} from 'native-base';
import { SafeAreaView } from 'react-native';
import { SearchContext } from '../views/SearchView';
import { useQuery } from '../Queries';
import { Translate, translate } from '../i18n/i18n';
import API from '../API';
import LoadingComponent, { LoadingView } from './Loading';
import ArtistCard from './ArtistCard';
import GenreCard from './GenreCard';
import SongCard from './SongCard';
import CardGridCustom from './CardGridCustom';
import SearchHistoryCard from './HistoryCard';
import Song from '../models/Song';
import { useNavigation } from '../Navigation';
import SongRow from '../components/SongRow';
import FavSongRow from './FavSongRow';
import { useLikeSongMutation } from '../utils/likeSongMutation';
const swaToSongCardProps = (song: Song) => ({
songId: song.id,
name: song.name,
artistName: song.artist!.name,
cover: song.cover,
});
const HomeSearchComponent = () => {
const { updateStringQuery } = React.useContext(SearchContext);
const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery(
API.getSearchHistory(0, 12),
{ enabled: true }
);
const songSuggestions = useQuery(API.getSongSuggestions(['artist']));
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>
{!songSuggestions.data ? (
<LoadingComponent />
) : (
<CardGridCustom
content={songSuggestions.data.map(swaToSongCardProps)}
cardComponent={SongCard}
/>
)}
</Card>
</VStack>
);
};
type SongsSearchComponentProps = {
maxRows?: number;
};
const SongsSearchComponent = (props: SongsSearchComponentProps) => {
const navigation = useNavigation();
const { songData } = React.useContext(SearchContext);
const favoritesQuery = useQuery(API.getLikedSongs(['artist']));
const { mutate } = useLikeSongMutation();
return (
<ScrollView>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{songData?.length ? (
songData.slice(0, props.maxRows).map((comp, index) => (
<SongRow
key={index}
song={comp}
isLiked={
!favoritesQuery.data?.find((query) => query?.songId == comp.id)
}
handleLike={async (state: boolean, songId: number) =>
mutate({ songId: songId, like: state })
}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');
navigation.navigate('Play', { songId: comp.id });
}}
/>
))
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
</ScrollView>
);
};
type ItemSearchComponentProps = {
maxItems?: number;
};
const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
const { artistData } = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Translate translationKey="artistFilter" fontSize="xl" fontWeight="bold" mt={4} />
{artistData?.length ? (
<CardGridCustom
content={artistData
.slice(0, props.maxItems ?? artistData.length)
.map((artistData) => ({
image: API.getArtistIllustration(artistData.id),
name: artistData.name,
id: artistData.id,
onPress: () => {
API.createSearchHistoryEntry(artistData.name, 'artist');
navigation.navigate('Artist', { artistId: artistData.id });
},
}))}
cardComponent={ArtistCard}
/>
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
);
};
const GenreSearchComponent = (props: ItemSearchComponentProps) => {
const { genreData } = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Translate translationKey="genreFilter" fontSize="xl" fontWeight="bold" mt={4} />
{genreData?.length ? (
<CardGridCustom
content={genreData.slice(0, props.maxItems ?? genreData.length).map((g) => ({
image: API.getGenreIllustration(g.id),
name: g.name,
id: g.id,
onPress: () => {
API.createSearchHistoryEntry(g.name, 'genre');
navigation.navigate('Genre', { genreId: g.id });
},
}))}
cardComponent={GenreCard}
/>
) : (
<Translate translationKey="errNoResults" />
)}
</Box>
);
};
const FavoritesComponent = () => {
const navigation = useNavigation();
const favoritesQuery = useQuery(API.getLikedSongs());
if (favoritesQuery.isError) {
navigation.navigate('Error');
return <></>;
}
if (!favoritesQuery.data) {
return <LoadingView />;
}
return (
<ScrollView>
<Translate translationKey="songsFilter" fontSize="xl" fontWeight="bold" mt={4} />
<Box>
{favoritesQuery.data?.map((songData) => (
<FavSongRow
key={songData.id}
song={songData.song}
addedDate={songData.addedDate}
onPress={() => {
API.createSearchHistoryEntry(songData.song.name, 'song'); //todo
navigation.navigate('Play', { songId: songData.song!.id });
}}
/>
))}
</Box>
</ScrollView>
);
};
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 />;
case 'favorites':
return <FavoritesComponent />;
default:
return (
<Translate translationKey="unknownError" format={(e) => `${e}: ${currentFilter}`} />
);
}
};
export const SearchResultComponent = () => {
const { stringQuery } = React.useContext(SearchContext);
const { filter } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim() || filter == 'favorites';
return shouldOutput ? (
<Box p={5}>
<FilterSwitch />
</Box>
) : (
<HomeSearchComponent />
);
};

View File

@@ -6,8 +6,8 @@ import ButtonBase from '../UI/ButtonBase';
import { AddSquare, CloseCircle, SearchNormal1 } from 'iconsax-react-native';
import { useQuery } from '../../Queries';
import API from '../../API';
import Genre from '../../models/Genre';
import { translate } from '../../i18n/i18n';
import { searchProps } from '../../views/V2/SearchView';
type ArtistChipProps = {
name: string;
@@ -29,9 +29,9 @@ const ArtistChipComponent = (props: ArtistChipProps) => {
}}
>
{props.selected ? (
<CloseCircle size="32" color={'#ED4A51'} />
<CloseCircle size="24" color={'#ED4A51'} />
) : (
<AddSquare size="32" color={'#6075F9'} />
<AddSquare size="24" color={'#6075F9'} />
)}
<Text>{props.name}</Text>
</View>
@@ -40,15 +40,24 @@ const ArtistChipComponent = (props: ArtistChipProps) => {
);
};
const SearchBarComponent = () => {
const SearchBarComponent = (props: { onValidate: (searchData: searchProps) => void }) => {
const [query, setQuery] = React.useState('');
const [genre, setGenre] = React.useState({} as Genre | undefined);
const [genre, setGenre] = React.useState('');
const [artist, setArtist] = React.useState('');
const artistsQuery = useQuery(API.getAllArtists());
const genresQuery = useQuery(API.getAllGenres());
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
const handleValidate = () => {
const searchData = {
query: query,
artist: artistsQuery.data?.find((a) => a.name === artist)?.id ?? undefined,
genre: genresQuery.data?.find((g) => g.name === genre)?.id ?? undefined,
};
props.onValidate(searchData);
};
return (
<View>
<View
@@ -57,7 +66,7 @@ const SearchBarComponent = () => {
borderBottomColor: '#9E9E9E',
display: 'flex',
flexDirection: isMobileView ? 'column' : 'row',
alignItems: 'center',
maxWidth: '100%',
width: '100%',
margin: 5,
padding: 16,
@@ -69,10 +78,11 @@ const SearchBarComponent = () => {
flexGrow: 0,
flexShrink: 0,
flexDirection: 'row',
flexWrap: 'wrap',
flexWrap: 'nowrap',
maxWidth: '100%',
}}
>
{artist && (
{!!artist && (
<ArtistChipComponent
onPress={() => setArtist('')}
name={artist}
@@ -82,36 +92,27 @@ const SearchBarComponent = () => {
</View>
<View
style={{
flex: 1,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexGrow: 1,
width: '100%',
}}
>
<View
style={{
flexGrow: 1,
flexShrink: 1,
}}
>
<View style={{ flex: 1 }}>
<Input
type="text"
value={query}
variant={'unstyled'}
placeholder={translate('searchBarPlaceholder')}
style={{ width: '100%', height: 30 }}
style={{ height: 30 }}
onChangeText={(value) => setQuery(value)}
/>
</View>
<ButtonBase
type="menu"
icon={SearchNormal1}
style={{
flexShrink: 0,
flexGrow: 0,
}}
style={{}}
onPress={handleValidate}
/>
</View>
</View>
@@ -146,6 +147,13 @@ const SearchBarComponent = () => {
key={index}
name={artist.name}
onPress={() => {
props.onValidate({
artist: artist.id,
genre:
genresQuery.data?.find((a) => a.name === genre)
?.id ?? undefined,
query: query,
});
setArtist(artist.name);
}}
/>
@@ -154,15 +162,20 @@ const SearchBarComponent = () => {
</ScrollView>
<View>
<Select
selectedValue={genre?.name}
selectedValue={genre}
placeholder={translate('genreFilter')}
accessibilityLabel="Genre"
onValueChange={(itemValue) => {
setGenre(
genresQuery.data?.find((genre) => {
genre.name == itemValue;
})
);
setGenre(itemValue);
props.onValidate({
artist:
artistsQuery.data?.find((a) => a.name === artist)?.id ??
undefined,
genre:
genresQuery.data?.find((g) => g.name === itemValue)?.id ??
undefined,
query: query,
});
}}
>
<Select.Item label={translate('emptySelection')} value="" />

View File

@@ -307,7 +307,7 @@ export const en = {
leaderBoardHeading: 'These are the best players',
leaderBoardHeadingFull:
'The players having the best scores, thanks to their exceptional accuracy, are highlighted here.',
emptySelection: 'None,',
emptySelection: 'None',
gamesPlayed: 'Games Played',
metronome: 'Metronome',
loading: 'Loading... Please Wait',

View File

@@ -1,109 +0,0 @@
import React, { useState } from 'react';
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 '../Queries';
import { SearchResultComponent } from '../components/SearchResult';
import { SafeAreaView } from 'react-native';
import { Filter } from '../components/SearchBar';
import { ScrollView } from 'native-base';
import LikedSong from '../models/LikedSong';
interface SearchContextType {
filter: 'artist' | 'song' | 'genre' | 'all' | 'favorites';
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all' | 'favorites') => void;
stringQuery: string;
updateStringQuery: (newData: string) => void;
songData: Song[];
artistData: Artist[];
genreData: Genre[];
favoriteData: LikedSong[];
isLoadingSong: boolean;
isLoadingArtist: boolean;
isLoadingGenre: boolean;
isLoadingFavorite: boolean;
}
export const SearchContext = React.createContext<SearchContextType>({
filter: 'all',
updateFilter: () => {},
stringQuery: '',
updateStringQuery: () => {},
songData: [],
artistData: [],
genreData: [],
favoriteData: [],
isLoadingSong: false,
isLoadingArtist: false,
isLoadingGenre: false,
isLoadingFavorite: false,
});
type SearchViewProps = {
query?: string;
};
const SearchView = (props: SearchViewProps) => {
const [filter, setFilter] = useState<Filter>('all');
const [stringQuery, setStringQuery] = useState<string>(props?.query ?? '');
const { isLoading: isLoadingSong, data: songData = [] } = useQuery(
API.searchSongs(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingArtist, data: artistData = [] } = useQuery(
API.searchArtists(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingGenre, data: genreData = [] } = useQuery(
API.searchGenres(stringQuery),
{ enabled: !!stringQuery }
);
const { isLoading: isLoadingFavorite, data: favoriteData = [] } = useQuery(
API.getLikedSongs(),
{ enabled: true }
);
const updateFilter = (newData: Filter) => {
// called when the filter is changed
setFilter(newData);
};
const updateStringQuery = (newData: string) => {
// called when the stringQuery is updated
setStringQuery(newData);
};
return (
<ScrollView>
<SafeAreaView>
<SearchContext.Provider
value={{
filter,
stringQuery,
songData,
artistData,
genreData,
favoriteData,
isLoadingSong,
isLoadingArtist,
isLoadingGenre,
isLoadingFavorite,
updateFilter,
updateStringQuery,
}}
>
<SearchBar />
<SearchResultComponent />
</SearchContext.Provider>
</SafeAreaView>
</ScrollView>
);
};
export default SearchView;

View File

@@ -1,12 +1,48 @@
import React from 'react';
import { View } from 'react-native';
import { useQuery } from '../../Queries';
import SearchBarComponent from '../../components/V2/SearchBar';
import SearchHistory from '../../components/V2/SearchHistory';
import { View } from 'react-native';
import API from '../../API';
import LoadingComponent from '../../components/Loading';
import MusicListCC from '../../components/UI/MusicList';
export type searchProps = {
artist: number | undefined;
genre: number | undefined;
query: string;
};
const SearchView = () => {
const artistsQuery = useQuery(API.getAllArtists());
const [searchQuery, setSearchQuery] = React.useState({} as searchProps);
const rawResult = useQuery(API.searchSongs(searchQuery, ['artist']), {
enabled: !!searchQuery.query || !!searchQuery.artist || !!searchQuery.genre,
onSuccess() {
const artist =
artistsQuery?.data?.find(({ id }) => id == searchQuery.artist)?.name ??
'unknown artist';
searchQuery.query ? API.createSearchHistoryEntry(searchQuery.query, 'song') : null;
if (artist != 'unknown artist') API.createSearchHistoryEntry(artist, 'artist');
},
});
if (artistsQuery.isLoading) {
return <LoadingComponent />;
}
return (
<View style={{ display: 'flex', gap: 50 }}>
<SearchBarComponent />
<SearchHistory />
<View style={{ display: 'flex', gap: 20 }}>
<SearchBarComponent onValidate={(query) => setSearchQuery(query)} />
{rawResult.isSuccess ? (
<MusicListCC
musics={rawResult.data}
isFetching={rawResult.isFetching}
refetch={rawResult.refetch}
/>
) : (
<SearchHistory />
)}
</View>
);
};