Handle includes in the home page

This commit is contained in:
2023-11-29 19:57:39 +01:00
parent 59a48ad060
commit eff5eae706
14 changed files with 149 additions and 238 deletions
+34 -27
View File
@@ -9,68 +9,75 @@ import {
Query,
Request,
UseGuards,
} from '@nestjs/common';
} from "@nestjs/common";
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
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';
import { SongHistory as _SongHistory } from 'src/_gen/prisma-class/song_history';
import { SearchHistory as _SearchHistory } from 'src/_gen/prisma-class/search_history';
} from "@nestjs/swagger";
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";
import { SongHistory as _SongHistory } from "src/_gen/prisma-class/song_history";
import { SearchHistory as _SearchHistory } from "src/_gen/prisma-class/search_history";
import { SongController } from "src/song/song.controller";
import { mapInclude } from "src/utils/include";
@Controller('history')
@ApiTags('history')
@Controller("history")
@ApiTags("history")
export class HistoryController {
constructor(private readonly historyService: HistoryService) {}
constructor(private readonly historyService: HistoryService) { }
@Get()
@HttpCode(200)
@ApiOperation({ description: 'Get song history of connected user' })
@ApiOperation({ description: "Get song history of connected user" })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SongHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async getHistory(
@Request() req: any,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("include") include: string,
): Promise<SongHistory[]> {
return this.historyService.getHistory(req.user.id, { skip, take });
return this.historyService.getHistory(
req.user.id,
{ skip, take },
mapInclude(include, req, SongController.includableFields),
);
}
@Get('search')
@Get("search")
@HttpCode(200)
@ApiOperation({ description: 'Get search history of connected user' })
@ApiOperation({ description: "Get search history of connected user" })
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: _SearchHistory, isArray: true })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async getSearchHistory(
@Request() req: any,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<SearchHistory[]> {
return this.historyService.getSearchHistory(req.user.id, { skip, take });
}
@Post()
@HttpCode(201)
@ApiOperation({ description: 'Create a record of a song played by a user' })
@ApiCreatedResponse({ description: 'Succesfully created a record' })
@ApiOperation({ description: "Create a record of a song played by a user" })
@ApiCreatedResponse({ description: "Succesfully created a record" })
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
return this.historyService.createSongHistoryRecord(record);
}
@Post('search')
@Post("search")
@HttpCode(201)
@ApiOperation({ description: 'Creates a search record in the users history' })
@ApiOperation({ description: "Creates a search record in the users history" })
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@ApiUnauthorizedResponse({ description: "Invalid token" })
async createSearchHistory(
@Request() req: any,
@Body() record: SearchHistoryDto,
+11 -10
View File
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import { SearchHistory, SongHistory } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { SearchHistoryDto } from './dto/SearchHistoryDto';
import { SongHistoryDto } from './dto/SongHistoryDto';
import { Injectable } from "@nestjs/common";
import { Prisma, SearchHistory, SongHistory } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { SearchHistoryDto } from "./dto/SearchHistoryDto";
import { SongHistoryDto } from "./dto/SongHistoryDto";
@Injectable()
export class HistoryService {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService) { }
async createSongHistoryRecord({
songID,
@@ -45,13 +45,14 @@ export class HistoryService {
async getHistory(
playerId: number,
{ skip, take }: { skip?: number; take?: number },
include?: Prisma.SongInclude,
): Promise<SongHistory[]> {
return this.prisma.songHistory.findMany({
where: { user: { id: playerId } },
orderBy: { playDate: 'desc' },
orderBy: { playDate: "desc" },
skip,
take,
include: { song: true }
include: { song: include ? { include } : true },
});
}
@@ -64,7 +65,7 @@ export class HistoryService {
}): Promise<{ best: number; history: SongHistory[] }> {
const history = await this.prisma.songHistory.findMany({
where: { user: { id: playerId }, song: { id: songId } },
orderBy: { playDate: 'desc' },
orderBy: { playDate: "desc" },
});
return {
@@ -96,7 +97,7 @@ export class HistoryService {
): Promise<SearchHistory[]> {
return this.prisma.searchHistory.findMany({
where: { user: { id: playerId } },
orderBy: { searchDate: 'desc' },
orderBy: { searchDate: "desc" },
skip,
take,
});
+16 -12
View File
@@ -5,7 +5,7 @@ import Lesson from './models/Lesson';
import Genre, { GenreHandler } from './models/Genre';
import LessonHistory from './models/LessonHistory';
import likedSong, { LikedSongHandler } from './models/LikedSong';
import Song, { SongHandler } from './models/Song';
import Song, { SongHandler, SongInclude } from './models/Song';
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
import User, { UserHandler } from './models/User';
import store from './state/Store';
@@ -277,13 +277,14 @@ export default class API {
};
}
public static getAllSongs(): Query<Song[]> {
public static getAllSongs(include?: SongInclude[]): Query<Song[]> {
include ??= [];
return {
key: 'songs',
key: ['songs', include],
exec: () =>
API.fetch(
{
route: '/song',
route: `/song?include=${include!.join(',')}`,
},
{
handler: PlageHandler(SongHandler),
@@ -332,13 +333,15 @@ export default class API {
* @param genreId the id of the genre we're aiming
* @returns a promise of an array of Songs
*/
public static getSongsByGenre(genreId: number): Query<Song[]> {
public static getSongsByGenre(genreId: number, includes?: SongInclude[]): Query<Song[]> {
includes ??= [];
return {
key: ['genre', genreId, 'songs'],
key: ['genre', genreId, 'songs', includes],
exec: () =>
API.fetch(
{
route: `/song?genreId=${genreId}`,
route: `/song?genreId=${genreId}&includes=${includes!.join(',')}`,
},
{ handler: PlageHandler(SongHandler) }
).then(({ data }) => data),
@@ -605,21 +608,22 @@ export default class API {
* Retrieve the authenticated user's recommendations
* @returns an array of songs
*/
public static getSongSuggestions(): Query<Song[]> {
return API.getAllSongs();
public static getSongSuggestions(include?: SongInclude[]): Query<Song[]> {
return API.getAllSongs(include);
}
/**
* Retrieve the authenticated user's play history
* * @returns an array of songs
*/
public static getUserPlayHistory(): Query<SongHistoryItem[]> {
public static getUserPlayHistory(include?: SongInclude[]): Query<SongHistoryItem[]> {
include ??= [];
return {
key: ['history'],
key: ['history', include],
exec: () =>
API.fetch(
{
route: '/history',
route: `/history?include=${include!.join(',')}`,
},
{ handler: ListHandler(SongHistoryItemHandler) }
),
+3 -3
View File
@@ -21,17 +21,17 @@ import GenreCard from './GenreCard';
import SongCard from './SongCard';
import CardGridCustom from './CardGridCustom';
import SearchHistoryCard from './HistoryCard';
import Song, { SongWithArtist } from '../models/Song';
import Song from '../models/Song';
import { useNavigation } from '../Navigation';
import Artist from '../models/Artist';
import SongRow from '../components/SongRow';
import FavSongRow from './FavSongRow';
import { LikedSongWithDetails } from '../models/LikedSong';
const swaToSongCardProps = (song: SongWithArtist) => ({
const swaToSongCardProps = (song: Song) => ({
songId: song.id,
name: song.name,
artistName: song.artist.name,
artistName: song.artist!.name,
cover: song.cover ?? 'https://picsum.photos/200',
});
+37 -43
View File
@@ -1,7 +1,7 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import { View, Image, TouchableOpacity } from 'react-native';
import { Divider, Text, ScrollView, Row, useMediaQuery, useTheme } from 'native-base';
import { useQuery, useQueries } from '../../Queries';
import { useQuery } from '../../Queries';
import API from '../../API';
import Song from '../../models/Song';
import ButtonBase from './ButtonBase';
@@ -30,42 +30,36 @@ type ScaffoldDesktopCCProps = {
// TODO a tester avec un historique de plus de 3 musics différente mdr !!
const SongHistory = (props: { quantity: number }) => {
const playHistoryQuery = useQuery(API.getUserPlayHistory);
const songHistory = useQueries(
playHistoryQuery.data?.map(({ songID }) => API.getSong(songID)) ?? []
);
const history = useQuery(API.getUserPlayHistory);
const navigation = useNavigation();
const musics = songHistory
.map((h) => h.data)
.filter((data): data is Song => data !== undefined)
.filter((song, i, array) => array.map((s) => s.id).findIndex((id) => id == song.id) == i)
?.slice(0, props.quantity)
.map((song: Song) => (
<View
key={'short-history-tab' + song.id}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
flex: 1,
}}
>
<TouchableOpacity onPress={() => navigation.navigate('Play', { songId: song.id })}>
<Text numberOfLines={1}>{song.name}</Text>
</TouchableOpacity>
</View>
));
if (!playHistoryQuery.data || playHistoryQuery.isLoading || !songHistory) {
if (!history.data || history.isLoading) {
return <LoadingView />;
}
const musics = history.data.map((h) => h.song)?.slice(0, props.quantity);
return (
<View>
{musics.length === 0 ? (
<Text style={{ paddingHorizontal: 16 }}>{translate('menuNoSongsPlayedYet')}</Text>
) : (
musics
musics.map((song) => (
<View
key={'short-history-tab' + song.id}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
flex: 1,
}}
>
<TouchableOpacity
onPress={() => navigation.navigate('Play', { songId: song.id })}
>
<Text numberOfLines={1}>{song.name}</Text>
</TouchableOpacity>
</View>
))
)}
</View>
);
@@ -127,14 +121,14 @@ const ScaffoldDesktopCC = (props: ScaffoldDesktopCCProps) => {
title={
!isSmallScreen
? translate(
value.title as
| 'menuDiscovery'
| 'menuProfile'
| 'menuMusic'
| 'menuSearch'
| 'menuLeaderBoard'
| 'menuSettings'
)
value.title as
| 'menuDiscovery'
| 'menuProfile'
| 'menuMusic'
| 'menuSearch'
| 'menuLeaderBoard'
| 'menuSettings'
)
: undefined
}
isDisabled={props.routeName === value.link}
@@ -183,14 +177,14 @@ const ScaffoldDesktopCC = (props: ScaffoldDesktopCCProps) => {
title={
!isSmallScreen
? translate(
value.title as
| 'menuDiscovery'
| 'menuProfile'
| 'menuMusic'
| 'menuSearch'
| 'menuLeaderBoard'
| 'menuSettings'
)
value.title as
| 'menuDiscovery'
| 'menuProfile'
| 'menuMusic'
| 'menuSearch'
| 'menuLeaderBoard'
| 'menuSettings'
)
: undefined
}
isDisabled={props.routeName === value.link}
+1 -1
View File
@@ -186,7 +186,7 @@ const SongCardInfo = (props: SongCardInfoProps) => {
fontWeight: 'normal',
}}
>
{props.song.artistId}
{props.song.artist?.name}
</Text>
</View>
<Ionicons
+1 -1
View File
@@ -4,7 +4,7 @@ import TabNavigationButton from './TabNavigationButton';
import TabNavigationList from './TabNavigationList';
import { useAssets } from 'expo-asset';
import useColorScheme from '../../hooks/colorScheme';
import { useQuery, useQueries } from '../../Queries';
import { useQuery } from '../../Queries';
import { NaviTab } from './TabNavigation';
import API from '../../API';
import Song from '../../models/Song';
+1 -1
View File
@@ -29,6 +29,6 @@ export const PlageHandler = <A, R>(
validator: PlageValidator(itemHandler.validator),
transformer: (plage) => ({
...plage,
data: plage.data.map((item) => itemHandler.transformer(item)),
data: plage.data.map((item) => itemHandler.transformer?.(item) ?? item as unknown as R),
}),
});
+18 -28
View File
@@ -1,9 +1,13 @@
import Model, { ModelValidator } from './Model';
import SongDetails, { SongDetailsHandler, SongDetailsValidator } from './SongDetails';
import Artist from './Artist';
import Artist, { ArtistValidator } from './Artist';
import * as yup from 'yup';
import ResponseHandler from './ResponseHandler';
import API from '../API';
import { AlbumValidator } from './Album';
import { GenreValidator } from './Genre';
export type SongInclude = 'artist' | 'album' | 'genre' | 'SongHistory' | 'likedByUsers';
export const SongValidator = yup
.object({
@@ -14,35 +18,21 @@ export const SongValidator = yup
albumId: yup.number().required().nullable(),
genreId: yup.number().required().nullable(),
difficulties: SongDetailsValidator.required(),
illustrationPath: yup.string().required(),
cover: yup.string().required(),
artist: yup.lazy(() => ArtistValidator.default(undefined)).optional(),
album: yup.lazy(() => AlbumValidator.default(undefined)).optional(),
genre: yup.lazy(() => GenreValidator.default(undefined)).optional(),
})
.concat(ModelValidator);
export const SongHandler: ResponseHandler<yup.InferType<typeof SongValidator>, Song> = {
validator: SongValidator,
transformer: (song) => ({
id: song.id,
name: song.name,
artistId: song.artistId,
albumId: song.albumId,
genreId: song.genreId,
details: SongDetailsHandler.transformer(song.difficulties),
.concat(ModelValidator)
.transform((song: Song) => ({
...song,
cover: `${API.baseUrl}/song/${song.id}/illustration`,
}),
}));
export type Song = yup.InferType<typeof SongValidator>;
export const SongHandler: ResponseHandler<Song> = {
validator: SongValidator,
};
interface Song extends Model {
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;
+3 -21
View File
@@ -17,28 +17,10 @@ export const SongDetailsValidator = yup.object({
chordcomplexity: yup.number().required(),
});
export const SongDetailsHandler: ResponseHandler<
yup.InferType<typeof SongDetailsValidator>,
SongDetails
> = {
export type SongDetails = yup.InferType<typeof SongDetailsValidator>;
export const SongDetailsHandler: ResponseHandler<SongDetails> = {
validator: SongDetailsValidator,
transformer: (value) => value,
};
interface SongDetails {
length: number;
rhythm: number;
arpeggio: number;
distance: number;
lefthand: number;
righthand: number;
twohands: number;
notecombo: number;
precision: number;
pedalpoint: number;
chordtiming: number;
leadhandchange: number;
chordcomplexity: number;
}
export default SongDetails;
+4 -16
View File
@@ -1,5 +1,5 @@
import { Flex, Heading, useBreakpointValue, ScrollView } from 'native-base';
import { useQueries, useQuery } from '../Queries';
import { useQuery } from '../Queries';
import { LoadingView } from '../components/Loading';
import { RouteProps, useNavigation } from '../Navigation';
import API from '../API';
@@ -13,19 +13,7 @@ type GenreDetailsViewProps = {
const GenreDetailsView = ({ genreId }: RouteProps<GenreDetailsViewProps>) => {
const genreQuery = useQuery(API.getGenre(genreId));
const songsQuery = useQuery(API.getSongsByGenre(genreId));
const artistQueries = useQueries(
songsQuery.data?.map((song) => song.artistId).map((artistId) => API.getArtist(artistId)) ??
[]
);
// Here, .artist will always be defined
const songWithArtist = songsQuery?.data
?.map((song) => ({
...song,
artist: artistQueries.find((query) => query.data?.id == song.artistId)?.data,
}))
.filter((song) => song.artist !== undefined);
const songsQuery = useQuery(API.getSongsByGenre(genreId, ["artist"]));
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
const navigation = useNavigation();
@@ -34,7 +22,7 @@ const GenreDetailsView = ({ genreId }: RouteProps<GenreDetailsViewProps>) => {
navigation.navigate('Error');
return <></>;
}
if (!genreQuery.data || songsQuery.data === undefined || songWithArtist === undefined) {
if (!genreQuery.data || songsQuery.data === undefined) {
return <LoadingView />;
}
@@ -54,7 +42,7 @@ const GenreDetailsView = ({ genreId }: RouteProps<GenreDetailsViewProps>) => {
mt={4}
>
<CardGridCustom
content={songWithArtist.map((songData) => ({
content={songsQuery.data.map((songData) => ({
name: songData.name,
cover: songData.cover,
artistName: songData.artist!.name,
+12 -46
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { useQueries, useQuery } from '../Queries';
import { useQuery } from '../Queries';
import API from '../API';
import { LoadingView } from '../components/Loading';
import { Box, Flex, Stack, Heading, VStack, HStack } from 'native-base';
@@ -16,20 +16,10 @@ import ScaffoldCC from '../components/UI/ScaffoldCC';
const HomeView = (props: RouteProps<{}>) => {
const navigation = useNavigation();
const userQuery = useQuery(API.getUserInfo);
const playHistoryQuery = useQuery(API.getUserPlayHistory);
const playHistoryQuery = useQuery(API.getUserPlayHistory(['artist']));
const searchHistoryQuery = useQuery(API.getSearchHistory(0, 10));
const skillsQuery = useQuery(API.getUserSkills);
const nextStepQuery = useQuery(API.getSongSuggestions);
const songHistory = useQueries(
playHistoryQuery.data?.map(({ songID }) => API.getSong(songID)) ?? []
);
const artistsQueries = useQueries(
songHistory
.map((entry) => entry.data)
.concat(nextStepQuery.data ?? [])
.filter((s): s is Song => s !== undefined)
.map((song) => API.getArtist(song.artistId))
);
const nextStepQuery = useQuery(API.getSongSuggestions(['artist']));
if (
!userQuery.data ||
@@ -46,20 +36,12 @@ const HomeView = (props: RouteProps<{}>) => {
<SongCardGrid
heading={<Translate translationKey="goNextStep" />}
songs={
nextStepQuery.data
?.filter((song) =>
artistsQueries.find(
(artistQuery) => artistQuery.data?.id === song.artistId
)
)
.map((song) => ({
cover: song.cover,
name: song.name,
songId: song.id,
artistName: artistsQueries.find(
(artistQuery) => artistQuery.data?.id === song.artistId
)!.data!.name,
})) ?? []
nextStepQuery.data?.map((song) => ({
cover: song.cover,
name: song.name,
songId: song.id,
artistName: song.artist!.name,
})) ?? []
}
/>
<Stack direction={{ base: 'column', lg: 'row' }}>
@@ -75,29 +57,13 @@ const HomeView = (props: RouteProps<{}>) => {
<SongCardGrid
heading={<Translate translationKey="recentlyPlayed" />}
songs={
songHistory
.map(({ data }) => data)
.filter((data): data is Song => data !== undefined)
.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
)
)
playHistoryQuery.data
?.map((x) => x.song)
.map((song) => ({
cover: song.cover,
name: song.name,
songId: song.id,
artistName: artistsQueries.find(
(artistQuery) =>
artistQuery.data?.id === song.artistId
)!.data!.name,
artistName: song.artist!.name,
})) ?? []
}
/>
+7 -28
View File
@@ -15,41 +15,20 @@ import { RouteProps, useNavigation } from '../Navigation';
import { TranslationKey, translate } from '../i18n/i18n';
import ScaffoldCC from '../components/UI/ScaffoldCC';
import MusicList from '../components/UI/MusicList';
import { useQueries, useQuery } from '../Queries';
import { useQuery } from '../Queries';
import API from '../API';
import Song from '../models/Song';
import { LoadingView } from '../components/Loading';
export const FavoritesMusic = () => {
const navigation = useNavigation();
const playHistoryQuery = useQuery(API.getUserPlayHistory);
const nextStepQuery = useQuery(API.getSongSuggestions);
const songHistory = useQueries(
playHistoryQuery.data?.map(({ songID }) => API.getSong(songID)) ?? []
);
const artistsQueries = useQueries(
songHistory
.map((entry) => entry.data)
.concat(nextStepQuery.data ?? [])
.filter((s): s is Song => s !== undefined)
.map((song) => API.getArtist(song.artistId))
);
const isLoading =
playHistoryQuery.isLoading ||
nextStepQuery.isLoading ||
songHistory.some((query) => query.isLoading) ||
artistsQueries.some((query) => query.isLoading);
const playHistoryQuery = useQuery(API.getUserPlayHistory(['artist']));
const musics =
nextStepQuery.data
?.filter((song: Song) =>
artistsQueries.find((artistQuery) => artistQuery.data?.id === song.artistId)
)
playHistoryQuery.data
?.map((x) => x.song)
.map((song: Song) => ({
artist: artistsQueries.find(
(artistQuery) => artistQuery.data?.id === song.artistId
)!.data!.name,
artist: song.artist!.name,
song: song.name,
image: song.cover,
level: 42,
@@ -62,7 +41,7 @@ export const FavoritesMusic = () => {
onPlay: () => navigation.navigate('Play', { songId: song.id }),
})) ?? [];
if (isLoading) {
if (playHistoryQuery.isLoading) {
return <LoadingView />;
}
return (
@@ -114,7 +93,7 @@ export const FavoritesMusic = () => {
</View> */}
<MusicList
initialMusics={musics}
// musicsPerPage={7}
// musicsPerPage={7}
/>
</>
);
+1 -1
View File
@@ -10,7 +10,7 @@ import GoldenRatio from '../../components/V2/GoldenRatio';
// eslint-disable-next-line @typescript-eslint/ban-types
const HomeView = (props: RouteProps<{}>) => {
const songsQuery = useQuery(API.getSongSuggestions);
const songsQuery = useQuery(API.getSongSuggestions(["artist"]));
const navigation = useNavigation();
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isPhone = screenSize === 'small';