Use includes on liked, music, score, search and fav pages

This commit is contained in:
2023-11-29 21:45:53 +01:00
parent eff5eae706
commit c0bc611268
9 changed files with 169 additions and 237 deletions

View File

@@ -21,12 +21,12 @@ import {
Response, Response,
Query, Query,
Param, Param,
} from '@nestjs/common'; } from "@nestjs/common";
import { AuthService } from './auth.service'; import { AuthService } from "./auth.service";
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from "./jwt-auth.guard";
import { LocalAuthGuard } from './local-auth.guard'; import { LocalAuthGuard } from "./local-auth.guard";
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from "./dto/register.dto";
import { UsersService } from 'src/users/users.service'; import { UsersService } from "src/users/users.service";
import { import {
ApiBadRequestResponse, ApiBadRequestResponse,
ApiBearerAuth, ApiBearerAuth,
@@ -36,39 +36,41 @@ import {
ApiOperation, ApiOperation,
ApiTags, ApiTags,
ApiUnauthorizedResponse, ApiUnauthorizedResponse,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import { User } from '../models/user'; import { User } from "../models/user";
import { JwtToken } from './models/jwt'; import { JwtToken } from "./models/jwt";
import { LoginDto } from './dto/login.dto'; import { LoginDto } from "./dto/login.dto";
import { Profile } from './dto/profile.dto'; import { Profile } from "./dto/profile.dto";
import { Setting } from 'src/models/setting'; import { Setting } from "src/models/setting";
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto'; import { UpdateSettingDto } from "src/settings/dto/update-setting.dto";
import { SettingsService } from 'src/settings/settings.service'; import { SettingsService } from "src/settings/settings.service";
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from "@nestjs/passport";
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from "@nestjs/platform-express";
import { writeFile } from 'fs'; import { writeFile } from "fs";
import { PasswordResetDto } from './dto/password_reset.dto '; import { PasswordResetDto } from "./dto/password_reset.dto ";
import { mapInclude } from "src/utils/include";
import { SongController } from "src/song/song.controller";
@ApiTags('auth') @ApiTags("auth")
@Controller('auth') @Controller("auth")
export class AuthController { export class AuthController {
constructor( constructor(
private authService: AuthService, private authService: AuthService,
private usersService: UsersService, private usersService: UsersService,
private settingsService: SettingsService, private settingsService: SettingsService,
) {} ) { }
@Get('login/google') @Get("login/google")
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard("google"))
@ApiOperation({ description: 'Redirect to google login page' }) @ApiOperation({ description: "Redirect to google login page" })
googleLogin() {} googleLogin() { }
@Get('logged/google') @Get("logged/google")
@ApiOperation({ @ApiOperation({
description: description:
'Redirect to the front page after connecting to the google account', "Redirect to the front page after connecting to the google account",
}) })
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard("google"))
async googleLoginCallbakc(@Req() req: any) { async googleLoginCallbakc(@Req() req: any) {
let user = await this.usersService.user({ googleID: req.user.googleID }); let user = await this.usersService.user({ googleID: req.user.googleID });
if (!user) { if (!user) {
@@ -78,13 +80,13 @@ export class AuthController {
return this.authService.login(user); return this.authService.login(user);
} }
@Post('register') @Post("register")
@ApiOperation({ description: 'Register a new user' }) @ApiOperation({ description: "Register a new user" })
@ApiConflictResponse({ description: 'Username or email already taken' }) @ApiConflictResponse({ description: "Username or email already taken" })
@ApiOkResponse({ @ApiOkResponse({
description: 'Successfully registered, email sent to verify', description: "Successfully registered, email sent to verify",
}) })
@ApiBadRequestResponse({ description: 'Invalid data or database error' }) @ApiBadRequestResponse({ description: "Invalid data or database error" })
async register(@Body() registerDto: RegisterDto): Promise<void> { async register(@Body() registerDto: RegisterDto): Promise<void> {
try { try {
const user = await this.usersService.createUser(registerDto); const user = await this.usersService.createUser(registerDto);
@@ -92,73 +94,73 @@ export class AuthController {
await this.authService.sendVerifyMail(user); await this.authService.sendVerifyMail(user);
} catch (e) { } catch (e) {
// check if the error is a duplicate key error // check if the error is a duplicate key error
if (e.code === 'P2002') { if (e.code === "P2002") {
throw new ConflictException('Username or email already taken'); throw new ConflictException("Username or email already taken");
} }
console.error(e); console.error(e);
throw new BadRequestException(); throw new BadRequestException();
} }
} }
@Put('verify') @Put("verify")
@HttpCode(200) @HttpCode(200)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ description: 'Verify the email of the user' }) @ApiOperation({ description: "Verify the email of the user" })
@ApiOkResponse({ description: 'Successfully verified' }) @ApiOkResponse({ description: "Successfully verified" })
@ApiBadRequestResponse({ description: 'Invalid or expired token' }) @ApiBadRequestResponse({ description: "Invalid or expired token" })
async verify( async verify(
@Request() req: any, @Request() req: any,
@Query('token') token: string, @Query("token") token: string,
): Promise<void> { ): Promise<void> {
if (await this.authService.verifyMail(req.user.id, token)) return; if (await this.authService.verifyMail(req.user.id, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.'); throw new BadRequestException("Invalid token. Expired or invalid.");
} }
@Put('reverify') @Put("reverify")
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(200) @HttpCode(200)
@ApiOperation({ description: 'Resend the verification email' }) @ApiOperation({ description: "Resend the verification email" })
async reverify(@Request() req: any): Promise<void> { async reverify(@Request() req: any): Promise<void> {
const user = await this.usersService.user({ id: req.user.id }); const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new BadRequestException('Invalid user'); if (!user) throw new BadRequestException("Invalid user");
await this.authService.sendVerifyMail(user); await this.authService.sendVerifyMail(user);
} }
@HttpCode(200) @HttpCode(200)
@Put('password-reset') @Put("password-reset")
async password_reset( async password_reset(
@Body() resetDto: PasswordResetDto, @Body() resetDto: PasswordResetDto,
@Query('token') token: string, @Query("token") token: string,
): Promise<void> { ): Promise<void> {
if (await this.authService.changePassword(resetDto.password, token)) return; if (await this.authService.changePassword(resetDto.password, token)) return;
throw new BadRequestException('Invalid token. Expired or invalid.'); throw new BadRequestException("Invalid token. Expired or invalid.");
} }
@HttpCode(200) @HttpCode(200)
@Put('forgot-password') @Put("forgot-password")
async forgot_password(@Query('email') email: string): Promise<void> { async forgot_password(@Query("email") email: string): Promise<void> {
console.log(email); console.log(email);
const user = await this.usersService.user({ email: email }); const user = await this.usersService.user({ email: email });
if (!user) throw new BadRequestException('Invalid user'); if (!user) throw new BadRequestException("Invalid user");
await this.authService.sendPasswordResetMail(user); await this.authService.sendPasswordResetMail(user);
} }
@Post('login') @Post("login")
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
@HttpCode(200) @HttpCode(200)
@UseGuards(LocalAuthGuard) @UseGuards(LocalAuthGuard)
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
@ApiOperation({ description: 'Login with username and password' }) @ApiOperation({ description: "Login with username and password" })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken }) @ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' }) @ApiUnauthorizedResponse({ description: "Invalid credentials" })
async login(@Request() req: any): Promise<JwtToken> { async login(@Request() req: any): Promise<JwtToken> {
return this.authService.login(req.user); return this.authService.login(req.user);
} }
@Post('guest') @Post("guest")
@HttpCode(200) @HttpCode(200)
@ApiOperation({ description: 'Login as a guest account' }) @ApiOperation({ description: "Login as a guest account" })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken }) @ApiOkResponse({ description: "Successfully logged in", type: JwtToken })
async guest(): Promise<JwtToken> { async guest(): Promise<JwtToken> {
const user = await this.usersService.createGuest(); const user = await this.usersService.createGuest();
await this.settingsService.createUserSetting(user.id); await this.settingsService.createUserSetting(user.id);
@@ -167,27 +169,27 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ description: 'Get the profile picture of connected user' }) @ApiOperation({ description: "Get the profile picture of connected user" })
@ApiOkResponse({ description: 'The user profile picture' }) @ApiOkResponse({ description: "The user profile picture" })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Get('me/picture') @Get("me/picture")
async getProfilePicture(@Request() req: any, @Response() res: any) { async getProfilePicture(@Request() req: any, @Response() res: any) {
return await this.usersService.getProfilePicture(req.user.id, res); return await this.usersService.getProfilePicture(req.user.id, res);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'The user profile picture' }) @ApiOkResponse({ description: "The user profile picture" })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Post('me/picture') @Post("me/picture")
@ApiOperation({ description: 'Upload a new profile picture' }) @ApiOperation({ description: "Upload a new profile picture" })
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor("file"))
async postProfilePicture( async postProfilePicture(
@Request() req: any, @Request() req: any,
@UploadedFile( @UploadedFile(
new ParseFilePipeBuilder() new ParseFilePipeBuilder()
.addFileTypeValidator({ .addFileTypeValidator({
fileType: 'jpeg', fileType: "jpeg",
}) })
.build({ .build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
@@ -203,10 +205,10 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully logged in', type: User }) @ApiOkResponse({ description: "Successfully logged in", type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Get('me') @Get("me")
@ApiOperation({ description: 'Get the user info of connected user' }) @ApiOperation({ description: "Get the user info of connected user" })
async getProfile(@Request() req: any): Promise<User> { async getProfile(@Request() req: any): Promise<User> {
const user = await this.usersService.user({ id: req.user.id }); const user = await this.usersService.user({ id: req.user.id });
if (!user) throw new InternalServerErrorException(); if (!user) throw new InternalServerErrorException();
@@ -215,10 +217,10 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited profile', type: User }) @ApiOkResponse({ description: "Successfully edited profile", type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Put('me') @Put("me")
@ApiOperation({ description: 'Edit the profile of connected user' }) @ApiOperation({ description: "Edit the profile of connected user" })
editProfile( editProfile(
@Request() req: any, @Request() req: any,
@Body() profile: Partial<Profile>, @Body() profile: Partial<Profile>,
@@ -241,20 +243,20 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully deleted', type: User }) @ApiOkResponse({ description: "Successfully deleted", type: User })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Delete('me') @Delete("me")
@ApiOperation({ description: 'Delete the profile of connected user' }) @ApiOperation({ description: "Delete the profile of connected user" })
deleteSelf(@Request() req: any): Promise<User> { deleteSelf(@Request() req: any): Promise<User> {
return this.usersService.deleteUser({ id: req.user.id }); return this.usersService.deleteUser({ id: req.user.id });
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting }) @ApiOkResponse({ description: "Successfully edited settings", type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Patch('me/settings') @Patch("me/settings")
@ApiOperation({ description: 'Edit the settings of connected user' }) @ApiOperation({ description: "Edit the settings of connected user" })
udpateSettings( udpateSettings(
@Request() req: any, @Request() req: any,
@Body() settingUserDto: UpdateSettingDto, @Body() settingUserDto: UpdateSettingDto,
@@ -267,10 +269,10 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting }) @ApiOkResponse({ description: "Successfully edited settings", type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Get('me/settings') @Get("me/settings")
@ApiOperation({ description: 'Get the settings of connected user' }) @ApiOperation({ description: "Get the settings of connected user" })
async getSettings(@Request() req: any): Promise<Setting> { async getSettings(@Request() req: any): Promise<Setting> {
const result = await this.settingsService.getUserSetting({ const result = await this.settingsService.getUserSetting({
userId: +req.user.id, userId: +req.user.id,
@@ -281,28 +283,31 @@ export class AuthController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully added liked song' }) @ApiOkResponse({ description: "Successfully added liked song" })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Post('me/likes/:id') @Post("me/likes/:id")
addLikedSong(@Request() req: any, @Param('id') songId: number) { addLikedSong(@Request() req: any, @Param("id") songId: number) {
return this.usersService.addLikedSong(+req.user.id, +songId); return this.usersService.addLikedSong(+req.user.id, +songId);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully removed liked song' }) @ApiOkResponse({ description: "Successfully removed liked song" })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Delete('me/likes/:id') @Delete("me/likes/:id")
removeLikedSong(@Request() req: any, @Param('id') songId: number) { removeLikedSong(@Request() req: any, @Param("id") songId: number) {
return this.usersService.removeLikedSong(+req.user.id, +songId); return this.usersService.removeLikedSong(+req.user.id, +songId);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully retrieved liked song' }) @ApiOkResponse({ description: "Successfully retrieved liked song" })
@ApiUnauthorizedResponse({ description: 'Invalid token' }) @ApiUnauthorizedResponse({ description: "Invalid token" })
@Get('me/likes') @Get("me/likes")
getLikedSongs(@Request() req: any) { getLikedSongs(@Request() req: any, @Query("include") include: string) {
return this.usersService.getLikedSongs(+req.user.id); return this.usersService.getLikedSongs(
+req.user.id,
mapInclude(include, req, SongController.includableFields),
);
} }
} }

View File

@@ -2,17 +2,17 @@ import {
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { User, Prisma } from '@prisma/client'; import { User, Prisma } from "@prisma/client";
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from "src/prisma/prisma.service";
import * as bcrypt from 'bcryptjs'; import * as bcrypt from "bcryptjs";
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from "crypto";
import { createReadStream, existsSync } from 'fs'; import { createReadStream, existsSync } from "fs";
import fetch from 'node-fetch'; import fetch from "node-fetch";
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) { }
async user( async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput, userWhereUniqueInput: Prisma.UserWhereUniqueInput,
@@ -53,7 +53,7 @@ export class UsersService {
isGuest: true, isGuest: true,
// Not realyl clean but better than a separate table or breaking the api by adding nulls. // Not realyl clean but better than a separate table or breaking the api by adding nulls.
email: null, email: null,
password: '', password: "",
}, },
}); });
} }
@@ -63,7 +63,7 @@ export class UsersService {
data: Prisma.UserUpdateInput; data: Prisma.UserUpdateInput;
}): Promise<User> { }): Promise<User> {
const { where, data } = params; const { where, data } = params;
if (typeof data.password === 'string') if (typeof data.password === "string")
data.password = await bcrypt.hash(data.password, 8); data.password = await bcrypt.hash(data.password, 8);
else if (data.password && data.password.set) else if (data.password && data.password.set)
data.password = await bcrypt.hash(data.password.set, 8); data.password = await bcrypt.hash(data.password.set, 8);
@@ -89,9 +89,9 @@ export class UsersService {
const user = await this.user({ id: userId }); const user = await this.user({ id: userId });
if (!user) throw new InternalServerErrorException(); if (!user) throw new InternalServerErrorException();
if (!user.email) throw new NotFoundException(); if (!user.email) throw new NotFoundException();
const hash = createHash('md5') const hash = createHash("md5")
.update(user.email.trim().toLowerCase()) .update(user.email.trim().toLowerCase())
.digest('hex'); .digest("hex");
const resp = await fetch( const resp = await fetch(
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`, `https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
); );
@@ -105,9 +105,10 @@ export class UsersService {
}); });
} }
async getLikedSongs(userId: number) { async getLikedSongs(userId: number, include?: Prisma.SongInclude) {
return this.prisma.likedSongs.findMany({ return this.prisma.likedSongs.findMany({
where: { userId: userId }, where: { userId: userId },
include: { song: include ? { include } : true },
}); });
} }

View File

@@ -4,7 +4,7 @@ import Chapter from './models/Chapter';
import Lesson from './models/Lesson'; import Lesson from './models/Lesson';
import Genre, { GenreHandler } from './models/Genre'; import Genre, { GenreHandler } from './models/Genre';
import LessonHistory from './models/LessonHistory'; import LessonHistory from './models/LessonHistory';
import likedSong, { LikedSongHandler } from './models/LikedSong'; import likedSong, { LikedSong, LikedSongHandler } from './models/LikedSong';
import Song, { SongHandler, SongInclude } from './models/Song'; import Song, { SongHandler, SongInclude } from './models/Song';
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory'; import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
import User, { UserHandler } from './models/User'; import User, { UserHandler } from './models/User';
@@ -297,13 +297,14 @@ export default class API {
* Retrieve a song * Retrieve a song
* @param songId the id to find the song * @param songId the id to find the song
*/ */
public static getSong(songId: number): Query<Song> { public static getSong(songId: number, include?: SongInclude[]): Query<Song> {
include ??= [];
return { return {
key: ['song', songId], key: ['song', songId, include],
exec: async () => exec: async () =>
API.fetch( API.fetch(
{ {
route: `/song/${songId}`, route: `/song/${songId}?include=${include!.join(',')}`,
}, },
{ handler: SongHandler } { handler: SongHandler }
), ),
@@ -702,13 +703,14 @@ export default class API {
}); });
} }
public static getLikedSongs(): Query<likedSong[]> { public static getLikedSongs(include?: SongInclude[]): Query<LikedSong[]> {
include ??= [];
return { return {
key: ['liked songs'], key: ['liked songs', include],
exec: () => exec: () =>
API.fetch( API.fetch(
{ {
route: '/auth/me/likes', route: `/auth/me/likes?include=${include!.join(',')}`,
}, },
{ handler: ListHandler(LikedSongHandler) } { handler: ListHandler(LikedSongHandler) }
), ),

View File

@@ -70,11 +70,4 @@ const transformQuery = <OldReturnType, NewReturnType>(
}; };
}; };
const useQueries = <ReturnTypes>( export { useQuery, QueryRules, transformQuery };
queries: readonly QueryOrQueryFn<ReturnTypes>[],
options?: QueryOptions<ReturnTypes>
) => {
return RQ.useQueries(queries.map((q) => buildRQuery(q, options)));
};
export { useQuery, useQueries, QueryRules, transformQuery };

View File

@@ -1,17 +1,18 @@
import { HStack, IconButton, Image, Text } from 'native-base'; import { HStack, IconButton, Image, Text } from 'native-base';
import RowCustom from './RowCustom'; import RowCustom from './RowCustom';
import TextButton from './TextButton'; import TextButton from './TextButton';
import { LikedSongWithDetails } from '../models/LikedSong';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import API from '../API'; import API from '../API';
import DurationComponent from './DurationComponent'; import DurationComponent from './DurationComponent';
import Song from '../models/Song';
type FavSongRowProps = { type FavSongRowProps = {
FavSong: LikedSongWithDetails; // TODO: remove Song song: Song;
addedDate: Date;
onPress: () => void; onPress: () => void;
}; };
const FavSongRow = ({ FavSong, onPress }: FavSongRowProps) => { const FavSongRow = ({ song, addedDate, onPress }: FavSongRowProps) => {
return ( return (
<RowCustom width={'100%'}> <RowCustom width={'100%'}>
<HStack px={2} space={5} justifyContent={'space-between'}> <HStack px={2} space={5} justifyContent={'space-between'}>
@@ -20,8 +21,8 @@ const FavSongRow = ({ FavSong, onPress }: FavSongRowProps) => {
flexGrow={0} flexGrow={0}
pl={10} pl={10}
style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }} style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }}
source={{ uri: FavSong.details.cover }} source={{ uri: song.cover }}
alt={FavSong.details.name} alt={song.name}
borderColor={'white'} borderColor={'white'}
borderWidth={1} borderWidth={1}
/> />
@@ -45,7 +46,7 @@ const FavSongRow = ({ FavSong, onPress }: FavSongRowProps) => {
bold bold
fontSize="md" fontSize="md"
> >
{FavSong.details.name} {song.name}
</Text> </Text>
<Text <Text
style={{ style={{
@@ -53,16 +54,16 @@ const FavSongRow = ({ FavSong, onPress }: FavSongRowProps) => {
}} }}
fontSize={'sm'} fontSize={'sm'}
> >
{FavSong.addedDate.toLocaleDateString()} {addedDate.toLocaleDateString()}
</Text> </Text>
<DurationComponent length={FavSong.details.details.length} /> <DurationComponent length={song.difficulties.length} />
</HStack> </HStack>
<IconButton <IconButton
colorScheme="primary" colorScheme="primary"
variant={'ghost'} variant={'ghost'}
borderRadius={'full'} borderRadius={'full'}
onPress={() => { onPress={() => {
API.removeLikedSong(FavSong.songId); API.removeLikedSong(song.id);
}} }}
_icon={{ _icon={{
as: MaterialIcons, as: MaterialIcons,

View File

@@ -12,7 +12,7 @@ import {
} from 'native-base'; } from 'native-base';
import { SafeAreaView } from 'react-native'; import { SafeAreaView } from 'react-native';
import { SearchContext } from '../views/SearchView'; import { SearchContext } from '../views/SearchView';
import { useQueries, useQuery } from '../Queries'; import { useQuery } from '../Queries';
import { translate } from '../i18n/i18n'; import { translate } from '../i18n/i18n';
import API from '../API'; import API from '../API';
import LoadingComponent, { LoadingView } from './Loading'; import LoadingComponent, { LoadingView } from './Loading';
@@ -26,7 +26,6 @@ import { useNavigation } from '../Navigation';
import Artist from '../models/Artist'; import Artist from '../models/Artist';
import SongRow from '../components/SongRow'; import SongRow from '../components/SongRow';
import FavSongRow from './FavSongRow'; import FavSongRow from './FavSongRow';
import { LikedSongWithDetails } from '../models/LikedSong';
const swaToSongCardProps = (song: Song) => ({ const swaToSongCardProps = (song: Song) => ({
songId: song.id, songId: song.id,
@@ -41,35 +40,7 @@ const HomeSearchComponent = () => {
API.getSearchHistory(0, 12), API.getSearchHistory(0, 12),
{ enabled: true } { enabled: true }
); );
const songSuggestions = useQuery(API.getSongSuggestions); const songSuggestions = useQuery(API.getSongSuggestions(['artist']));
const songArtistSuggestions = useQueries(
songSuggestions.data
?.filter((song) => song.artistId !== null)
.map(({ artistId }) => API.getArtist(artistId)) ?? []
);
const isLoadingSuggestions = useMemo(
() => songSuggestions.isLoading || songArtistSuggestions.some((q) => q.isLoading),
[songSuggestions, songArtistSuggestions]
);
const suggestionsData = useMemo(() => {
if (isLoadingSuggestions) {
return [];
}
return (
songSuggestions.data
?.map((song): [Song, Artist | undefined] => [
song,
songArtistSuggestions
.map((q) => q.data)
.filter((d) => d !== undefined)
.find((data) => data?.id === song.artistId),
])
// We do not need the song
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([song, artist]) => artist !== undefined)
.map(([song, artist]) => ({ ...song, artist: artist! })) ?? []
);
}, [songSuggestions, songArtistSuggestions]);
return ( return (
<VStack mt="5" style={{ overflow: 'hidden' }}> <VStack mt="5" style={{ overflow: 'hidden' }}>
@@ -94,11 +65,11 @@ const HomeSearchComponent = () => {
</Card> </Card>
<Card shadow={3} mt={5} mb={5}> <Card shadow={3} mt={5} mb={5}>
<Heading margin={5}>{translate('songsToGetBetter')}</Heading> <Heading margin={5}>{translate('songsToGetBetter')}</Heading>
{isLoadingSuggestions ? ( {!songSuggestions.data ? (
<LoadingComponent /> <LoadingComponent />
) : ( ) : (
<CardGridCustom <CardGridCustom
content={suggestionsData.map(swaToSongCardProps)} content={songSuggestions.data.map(swaToSongCardProps)}
cardComponent={SongCard} cardComponent={SongCard}
/> />
)} )}
@@ -219,19 +190,6 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
const FavoritesComponent = () => { const FavoritesComponent = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const favoritesQuery = useQuery(API.getLikedSongs()); const favoritesQuery = useQuery(API.getLikedSongs());
const songQueries = useQueries(
favoritesQuery.data
?.map((favorite) => favorite.songId)
.map((songId) => API.getSong(songId)) ?? []
);
const favSongWithDetails = favoritesQuery?.data
?.map((favorite) => ({
...favorite,
details: songQueries.find((query) => query.data?.id == favorite.songId)?.data,
}))
.filter((favorite) => favorite.details !== undefined)
.map((likedSong) => likedSong as LikedSongWithDetails);
if (favoritesQuery.isError) { if (favoritesQuery.isError) {
navigation.navigate('Error'); navigation.navigate('Error');
@@ -247,13 +205,14 @@ const FavoritesComponent = () => {
{translate('songsFilter')} {translate('songsFilter')}
</Text> </Text>
<Box> <Box>
{favSongWithDetails?.map((songData) => ( {favoritesQuery.data?.map((songData) => (
<FavSongRow <FavSongRow
key={songData.id} key={songData.id}
FavSong={songData} song={songData.song}
addedDate={songData.addedDate}
onPress={() => { onPress={() => {
API.createSearchHistoryEntry(songData.details!.name, 'song'); //todo API.createSearchHistoryEntry(songData.song.name, 'song'); //todo
navigation.navigate('Play', { songId: songData.details!.id }); navigation.navigate('Play', { songId: songData.song!.id });
}} }}
/> />
))} ))}

View File

@@ -1,33 +1,20 @@
import * as yup from 'yup'; import * as yup from 'yup';
import ResponseHandler from './ResponseHandler'; import ResponseHandler from './ResponseHandler';
import Song from './Song'; import Song, { SongValidator } from './Song';
import Model, { ModelValidator } from './Model'; import Model, { ModelValidator } from './Model';
export const LikedSongValidator = yup export const LikedSongValidator = yup
.object({ .object({
songId: yup.number().required(), songId: yup.number().required(),
song: yup.lazy(() => SongValidator.default(undefined)),
addedDate: yup.date().required(), addedDate: yup.date().required(),
}) })
.concat(ModelValidator); .concat(ModelValidator);
export const LikedSongHandler: ResponseHandler< export type LikedSong = yup.InferType<typeof LikedSongValidator>;
yup.InferType<typeof LikedSongValidator>,
LikedSong
> = {
validator: LikedSongValidator,
transformer: (likedSong) => ({
id: likedSong.id,
songId: likedSong.songId,
addedDate: likedSong.addedDate,
}),
};
interface LikedSong extends Model {
songId: number;
addedDate: Date;
}
export interface LikedSongWithDetails extends LikedSong { export const LikedSongHandler: ResponseHandler<LikedSong> = {
details: Song; validator: LikedSongValidator,
} };
export default LikedSong; export default LikedSong;

View File

@@ -22,26 +22,25 @@ import { LoadingView } from '../components/Loading';
export const FavoritesMusic = () => { export const FavoritesMusic = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const playHistoryQuery = useQuery(API.getUserPlayHistory(['artist'])); const likedSongs = useQuery(API.getLikedSongs(['artist']));
const musics = const musics =
playHistoryQuery.data likedSongs.data
?.map((x) => x.song) ?.map((x) => ({
.map((song: Song) => ({ artist: x.song.artist!.name,
artist: song.artist!.name, song: x.song.name,
song: song.name, image: x.song.cover,
image: song.cover,
level: 42, level: 42,
lastScore: 42, lastScore: 42,
bestScore: 42, bestScore: 42,
liked: false, liked: true,
onLike: () => { onLike: () => {
console.log('onLike'); console.log('onLike');
}, },
onPlay: () => navigation.navigate('Play', { songId: song.id }), onPlay: () => navigation.navigate('Play', { songId: x.song.id }),
})) ?? []; })) ?? [];
if (playHistoryQuery.isLoading) { if (likedSongs.isLoading) {
return <LoadingView />; return <LoadingView />;
} }
return ( return (

View File

@@ -6,7 +6,7 @@ import TextButton from '../components/TextButton';
import API from '../API'; import API from '../API';
import CardGridCustom from '../components/CardGridCustom'; import CardGridCustom from '../components/CardGridCustom';
import SongCard from '../components/SongCard'; import SongCard from '../components/SongCard';
import { useQueries, useQuery } from '../Queries'; import { useQuery } from '../Queries';
import { LoadingView } from '../components/Loading'; import { LoadingView } from '../components/Loading';
import ScoreGraph from '../components/ScoreGraph'; import ScoreGraph from '../components/ScoreGraph';
@@ -29,23 +29,10 @@ type ScoreViewProps = {
const ScoreView = (props: RouteProps<ScoreViewProps>) => { const ScoreView = (props: RouteProps<ScoreViewProps>) => {
const { songId, overallScore, precision, score } = props; const { songId, overallScore, precision, score } = props;
const navigation = useNavigation(); const navigation = useNavigation();
const songQuery = useQuery(API.getSong(songId)); const songQuery = useQuery(API.getSong(songId, ['artist']));
const artistQuery = useQuery(() => API.getArtist(songQuery.data!.artistId!), { const recommendations = useQuery(API.getSongSuggestions(['artist']));
enabled: songQuery.data !== undefined,
});
const recommendations = useQuery(API.getSongSuggestions);
const artistRecommendations = useQueries(
recommendations.data
?.filter(({ artistId }) => artistId !== null)
.map((song) => API.getArtist(song.artistId)) ?? []
);
if ( if (!recommendations.data || !songQuery.data) {
!recommendations.data ||
artistRecommendations.find(({ data }) => !data) ||
!songQuery.data ||
(songQuery.data.artistId && !artistQuery.data)
) {
return <LoadingView />; return <LoadingView />;
} }
if (songQuery.isError) { if (songQuery.isError) {
@@ -59,7 +46,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
<Text bold fontSize="lg"> <Text bold fontSize="lg">
{songQuery.data.name} {songQuery.data.name}
</Text> </Text>
<Text bold>{artistQuery.data?.name}</Text> <Text bold>{songQuery.data.artist!.name}</Text>
<Row style={{ justifyContent: 'center', display: 'flex' }}> <Row style={{ justifyContent: 'center', display: 'flex' }}>
<Card shadow={3} style={{ flex: 1 }}> <Card shadow={3} style={{ flex: 1 }}>
<Image <Image
@@ -144,9 +131,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
content={recommendations.data.map((i) => ({ content={recommendations.data.map((i) => ({
cover: i.cover, cover: i.cover,
name: i.name, name: i.name,
artistName: artistName: i.artist!.name,
artistRecommendations.find(({ data }) => data?.id == i.artistId)?.data
?.name ?? '',
songId: i.id, songId: i.id,
}))} }))}
cardComponent={SongCard} cardComponent={SongCard}