33 Commits

Author SHA1 Message Date
danis
daa82f16e3 Merge branch 'main' into feature/adc/artist-view 2023-09-05 09:45:28 +02:00
danis
f96aedef8c fix liked songs partial implementation linked error 2023-09-05 09:44:36 +02:00
danis
01394056a6 Merge branch 'feature/adc/artist-view' into feature/adc/#224-genre-view 2023-09-04 14:24:21 +02:00
danis
1396fcb39c artist name fix 2023-09-04 11:05:33 +02:00
danis
1255343b97 artist view + moved components 2023-08-12 11:16:22 +02:00
danis
f7562c18bd basic genre details view 2023-08-12 10:43:02 +02:00
Arthur Jamet
a3676fabf8 Front: Update User Avatar (#250)
* Front: Update User Avatar

* Front: Fix expo-image-picker version
2023-08-07 10:28:55 +02:00
Arthur Jamet
9f542fc9dd Front: User Avatar 2023-07-26 21:00:41 +09:00
930191569f Fix upload file issue 2023-07-26 21:00:41 +09:00
74cd9c0df2 Remove a usless validator 2023-07-26 21:00:41 +09:00
d2642b4fb8 Fixing gravatar 2023-07-26 21:00:41 +09:00
ebcc48cc57 Upgrade back packages 2023-07-26 21:00:41 +09:00
95b08935cc Add file upload 2023-07-26 21:00:41 +09:00
04487c9b24 Add get profile route that supports gravatar 2023-07-26 21:00:41 +09:00
Arthur Jamet
20eb62d19b Front: Graphes de Score (#248) 2023-07-26 12:00:06 +01:00
Zoe Roux
567d3250e2 Merge pull request #234 from Chroma-Case/feat/google 2023-07-24 19:40:15 +09:00
4207d5ee50 Try to fix the CI 2023-07-24 19:33:25 +09:00
Arthur Jamet
c0d9ee7ca6 Front: Merge 2023-07-16 18:11:34 +01:00
danis
bf09a25eb5 linear gradient 2023-07-11 10:06:55 +02:00
danis
373128ba53 broke my glasses 2023-07-10 23:12:37 +02:00
danis
3a09d10d3b you miss 100% of the shots you dont take 2023-07-09 23:24:31 +02:00
Arthur Jamet
87de52cae0 Front: 'Get Song By Artist' Query: fix typings 2023-07-05 14:18:31 +01:00
Arthur Jamet
931fe13eee Merge branch 'main' of github.com:Chroma-Case/Chromacase into feature/adc/artist-view 2023-07-05 14:06:27 +01:00
danis
28716eeab2 init genreDetailsView 2023-07-05 09:26:45 +02:00
Arthur Jamet
27f7945289 Front: Use React-Native feature to handle Google Redirections 2023-06-29 15:02:06 +01:00
danis
606af3901c Merge branch 'main' into feature/adc/artist-view 2023-06-28 09:22:25 +02:00
danis
b2247e79ae having a bug with api :/ 2023-06-28 09:11:49 +02:00
Arthur Jamet
3d76834f45 Front: Add Missing Translation + Prettier 2023-06-26 15:00:35 +01:00
ccc86895e2 Add an indicator of the google account on the front 2023-06-26 22:41:07 +09:00
279d16d59a Add google things on the front 2023-06-26 22:38:59 +09:00
04d288b844 Add google signin/signup 2023-06-26 22:38:59 +09:00
danis
a6ae770194 Merge branch 'main' into feature/adc/artist-view 2023-06-21 09:19:04 +02:00
danis
e378465126 components RowCustom & SongRow + artist banner 2023-06-21 08:21:34 +02:00
46 changed files with 6250 additions and 6199 deletions

View File

@@ -7,4 +7,6 @@ JWT_SECRET=wow
POSTGRES_DB=chromacase
API_URL=http://localhost:80/api
SCORO_URL=ws://localhost:6543
GOOGLE_CLIENT_ID=toto
GOOGLE_SECRET=tata
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google

View File

@@ -42,7 +42,7 @@ jobs:
- name: Install dependencies
run: yarn install
- name: Type Check
run: yarn tsc
- name: Check Prettier
@@ -84,16 +84,7 @@ jobs:
fetch-depth: 0
- name: Copy env file to github secret env file
run: |
touch .env
echo "POSTGRES_USER=user" >> .env
echo "POSTGRES_PASSWORD=eip" >> .env
echo "POSTGRES_NAME=chromacase" >> .env
echo "POSTGRES_HOST=db" >> .env
echo "DATABASE_URL=postgresql://user:eip@db:5432/chromacase" >> .env
echo "JWT_SECRET=wow" >> .env
echo "POSTGRES_DB=chromacase" >> .env
echo "API_URL=http://localhost:80/api" >> .env
run: cp .env.example .env
- name: Start the service
run: docker-compose up -d back db
@@ -101,6 +92,7 @@ jobs:
- name: Perform healthchecks
run: |
docker-compose ps -a
docker-compose logs
wget --retry-connrefused http://localhost:3000 # /healthcheck
- name: Run scorometer tests

11092
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,51 +21,55 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^8.0.0",
"@nestjs/config": "^2.1.0",
"@nestjs/core": "^8.0.0",
"@nestjs/jwt": "^8.0.1",
"@nestjs/common": "^10.1.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.1.0",
"@nestjs/jwt": "^10.1.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^8.2.2",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.2.1",
"@prisma/client": "^4.4.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.1.0",
"@nestjs/swagger": "^7.1.2",
"@prisma/client": "^5.0.0",
"@types/bcrypt": "^5.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/passport": "^1.0.9",
"@types/passport": "^1.0.12",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"passport-jwt": "^4.0.0",
"class-validator": "^0.14.0",
"node-fetch": "^2.6.12",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.5.0"
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
"@nestjs/schematics": "^8.0.0",
"@nestjs/testing": "^8.0.0",
"@types/express": "^4.17.13",
"@types/jest": "27.4.1",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.2.5",
"prettier": "^2.3.2",
"prisma": "^4.4.0",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.10.1",
"typescript": "^4.3.5"
"@nestjs/cli": "^10.1.10",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.1.0",
"@types/express": "^4.17.17",
"@types/jest": "29.5.3",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.4",
"@types/passport-google-oauth20": "^2.0.11",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.6.1",
"prettier": "^3.0.0",
"prisma": "^5.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.6"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[googleID]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "googleID" TEXT,
ALTER COLUMN "password" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_googleID_key" ON "User"("googleID");

View File

@@ -12,8 +12,9 @@ datasource db {
model User {
id Int @id @default(autoincrement())
username String @unique
password String
password String?
email String
googleID String? @unique
isGuest Boolean @default(false)
partyPlayed Int @default(0)
LessonHistory LessonHistory[]

View File

@@ -12,6 +12,12 @@ import {
InternalServerErrorException,
Patch,
NotFoundException,
Req,
UseInterceptors,
UploadedFile,
HttpStatus,
ParseFilePipeBuilder,
Response,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -32,6 +38,9 @@ import { Profile } from './dto/profile.dto';
import { Setting } from 'src/models/setting';
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
import { SettingsService } from 'src/settings/settings.service';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { writeFile } from 'fs';
@ApiTags('auth')
@Controller('auth')
@@ -42,12 +51,27 @@ export class AuthController {
private settingsService: SettingsService,
) {}
@Get('login/google')
@UseGuards(AuthGuard('google'))
googleLogin() {}
@Get('logged/google')
@UseGuards(AuthGuard('google'))
async googleLoginCallbakc(@Req() req: any) {
let user = await this.usersService.user({ googleID: req.user.googleID });
if (!user) {
user = await this.usersService.createUser(req.user);
await this.settingsService.createUserSetting(user.id);
}
return this.authService.login(user);
}
@Post('register')
async register(@Body() registerDto: RegisterDto): Promise<void> {
try {
const user = await this.usersService.createUser(registerDto)
const user = await this.usersService.createUser(registerDto);
await this.settingsService.createUserSetting(user.id);
} catch(e) {
} catch (e) {
console.error(e);
throw new BadRequestException();
}
@@ -69,6 +93,40 @@ export class AuthController {
return this.authService.login(user);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/picture')
async getProfilePicture(@Request() req: any, @Response() res: any) {
return await this.usersService.getProfilePicture(req.user.id, res);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'The user profile picture' })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Post('me/picture')
@UseInterceptors(FileInterceptor('file'))
async postProfilePicture(
@Request() req: any,
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({
fileType: 'jpeg',
})
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
}),
)
file: Express.Multer.File,
) {
const path = `/data/${req.user.id}.jpg`
writeFile(path, file.buffer, (err) => {
if (err) throw err;
});
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ description: 'Successfully logged in', type: User })
@@ -116,25 +174,28 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
@ApiUnauthorizedResponse({description: 'Invalid token'})
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Patch('me/settings')
udpateSettings(
@Request() req: any,
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
@Body() settingUserDto: UpdateSettingDto,
): Promise<Setting> {
return this.settingsService.updateUserSettings({
where: { userId: +req.user.id},
where: { userId: +req.user.id },
data: settingUserDto,
});
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
@ApiUnauthorizedResponse({description: 'Invalid token'})
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
@Get('me/settings')
async getSettings(@Request() req: any): Promise<Setting> {
const result = await this.settingsService.getUserSetting({ userId: +req.user.id });
const result = await this.settingsService.getUserSetting({
userId: +req.user.id,
});
if (!result) throw new NotFoundException();
return result;
}

View File

@@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';
import { SettingsModule } from 'src/settings/settings.module';
import { GoogleStrategy } from './google.strategy';
@Module({
imports: [
@@ -25,7 +26,7 @@ import { SettingsModule } from 'src/settings/settings.module';
inject: [ConfigService],
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -15,7 +15,7 @@ export class AuthService {
password: string,
): Promise<PayloadInterface | null> {
const user = await this.userService.user({ username });
if (user && bcrypt.compareSync(password, user.password)) {
if (user && user.password && bcrypt.compareSync(password, user.password)) {
return {
username: user.username,
id: user.id,

View File

@@ -0,0 +1,35 @@
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['email', 'profile'],
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<any> {
const user = {
email: profile.emails[0].value,
username: profile.displayName,
password: null,
googleID: profile.id,
// firstName: name.givenName,
// lastName: name.familyName,
// picture: photos[0].value,
};
done(null, user);
return user;
}
}

View File

@@ -1,13 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaService } from './prisma/prisma.service';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);
app.enableShutdownHooks();
const config = new DocumentBuilder()
.setTitle('Chromacase')

View File

@@ -1,4 +1,4 @@
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
@@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@@ -146,4 +146,10 @@ export class SongController {
songId: id,
});
}
@Get('/artist/:artistId')
async getSongByArtist(@Param('artistId', ParseIntPipe) artistId: number) {
const res = await this.songService.songByArtist(artistId)
return res;
}
}

View File

@@ -6,6 +6,14 @@ import { PrismaService } from 'src/prisma/prisma.service';
export class SongService {
constructor(private prisma: PrismaService) {}
async songByArtist(data: number): Promise<Song[]> {
return this.prisma.song.findMany({
where: {
artistId: {equals: data},
},
});
}
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
return this.prisma.song.create({
data,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { Controller, Get, Param, NotFoundException, Response } from '@nestjs/common';
import { UsersService } from './users.service';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { User } from 'src/models/user';
@@ -20,4 +20,9 @@ export class UsersController {
if (!ret) throw new NotFoundException();
return ret;
}
@Get(':id/picture')
async getPicture(@Response() res: any, @Param('id') id: number) {
return await this.usersService.getProfilePicture(+id, res);
}
}

View File

@@ -1,8 +1,15 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
NotFoundException,
StreamableFile,
} from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { createReadStream, existsSync } from 'fs';
import fetch from 'node-fetch';
@Injectable()
export class UsersService {
@@ -34,7 +41,7 @@ export class UsersService {
}
async createUser(data: Prisma.UserCreateInput): Promise<User> {
data.password = await bcrypt.hash(data.password, 8);
if (data.password) data.password = await bcrypt.hash(data.password, 8);
return this.prisma.user.create({
data,
});
@@ -72,4 +79,24 @@ export class UsersService {
where,
});
}
async getProfilePicture(userId: number, res: any) {
const path = `/data/${userId}.jpg`;
if (existsSync(path)) {
const file = createReadStream(path);
return file.pipe(res);
}
// We could not find a profile icon locally, using gravatar instead.
const user = await this.user({ id: userId });
if (!user) throw new InternalServerErrorException();
const hash = createHash('md5')
.update(user.email.trim().toLowerCase())
.digest('hex');
const resp = await fetch(
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
);
for (const [k, v] of resp.headers)
resp.headers.set(k, v);
resp.body!.pipe(res);
}
}

1
data/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

View File

@@ -9,6 +9,7 @@ services:
volumes:
- ./back:/app
- ./assets:/assets
- ./data:/data
depends_on:
db:
condition: service_healthy

View File

@@ -10,6 +10,7 @@ services:
- .env
volumes:
- ./assets:/assets
- ./data:/data
scorometer:
image: ghcr.io/chroma-case/scorometer:main
ports:

View File

@@ -10,6 +10,7 @@ services:
- .env
volumes:
- ./assets:/assets
- ./data:/data
scorometer:
build: ./scorometer
ports:

View File

@@ -21,6 +21,8 @@ import { PlageHandler } from './models/Plage';
import { ListHandler } from './models/List';
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
import * as yup from 'yup';
import { base64ToBlob } from 'file64';
import { ImagePickerAsset } from 'expo-image-picker';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -30,6 +32,7 @@ export type AccessToken = string;
type FetchParams = {
route: string;
body?: object;
formData?: FormData;
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
};
@@ -81,17 +84,22 @@ export default class API {
public static async fetch(params: FetchParams): Promise<void>;
public static async fetch(params: FetchParams, handle?: HandleParams) {
const jwtToken = store.getState().user.accessToken;
const header = {
'Content-Type': 'application/json',
const headers = {
...(params.formData == undefined && { 'Content-Type': 'application/json' }),
...(jwtToken && { Authorization: `Bearer ${jwtToken}` }),
};
const response = await fetch(`${API.baseUrl}${params.route}`, {
headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header,
body: JSON.stringify(params.body),
headers: headers,
body: params.formData ?? JSON.stringify(params.body),
method: params.method ?? 'GET',
}).catch(() => {
throw new Error('Error while fetching API: ' + API.baseUrl);
});
if (!handle || handle.emptyResponse) {
if (!response.ok) {
console.log(await response.json());
throw new APIError(response.statusText, response.status);
}
return;
}
if (handle.raw) {
@@ -164,6 +172,7 @@ export default class API {
{
route: '/auth/guest',
method: 'POST',
body: undefined,
},
{ handler: AccessTokenResponseHandler }
)
@@ -278,6 +287,25 @@ export default class API {
),
};
}
/**
* @description retrieves songs from a specific artist
* @param artistId is the id of the artist that composed the songs aimed
* @returns a Promise of Songs type array
*/
public static getSongsByArtist(artistId: number): Query<Song[]> {
return {
key: ['artist', artistId, 'songs'],
exec: () =>
API.fetch(
{
route: `/song?artistId=${artistId}`,
},
{ handler: PlageHandler(SongHandler) }
).then(({ data }) => data),
};
}
/**
* Retrive a song's midi partition
* @param songId the id to find the song
@@ -313,6 +341,16 @@ export default class API {
return `${API.baseUrl}/genre/${genreId}/illustration`;
}
// public static getGenre(genreId: number): Query<Genre> {
// return {
// key: ['genre', genreId],
// exec: () =>
// API.fetch({
// route: `/genre/${genreId}`,
// }),
// }
// }
/**
* Retrive a song's musicXML partition
* @param songId the id to find the song
@@ -479,6 +517,16 @@ export default class API {
};
}
// public static getFavorites(): Query<Song[]> {
// return {
// key: 'favorites',
// exec: () =>
// API.fetch({
// route: '/search/songs/o',
// }),
// };
// }
/**
* Retrieve the authenticated user's search history
* @param skip number of entries skipped before returning
@@ -587,4 +635,16 @@ export default class API {
{ handler: UserHandler }
);
}
public static async updateProfileAvatar(image: ImagePickerAsset): Promise<void> {
const data = await base64ToBlob(image.uri);
const formData = new FormData();
formData.append('file', data);
return API.fetch({
route: '/auth/me/picture',
method: 'POST',
formData,
});
}
}

View File

@@ -28,6 +28,11 @@ import { Button, Center, VStack } from 'native-base';
import { unsetAccessToken } from './state/UserSlice';
import TextButton from './components/TextButton';
import ErrorView from './views/ErrorView';
<<<<<<< HEAD
import GenreDetailsView from './views/GenreDetailsView';
=======
import GoogleView from './views/GoogleView';
>>>>>>> main
// Util function to hide route props in URL
const removeMe = () => '';
@@ -58,6 +63,11 @@ const protectedRoutes = () =>
options: { title: translate('artistFilter') },
link: '/artist/:artistId',
},
Genre: {
component: GenreDetailsView,
options: { title: translate('genreFilter')},
link: '/genre/:genreId',
},
Score: {
component: ScoreView,
options: { title: translate('score'), headerLeft: null },
@@ -100,6 +110,11 @@ const publicRoutes = () =>
options: { title: 'Oops', headerShown: false },
link: undefined,
},
Google: {
component: GoogleView,
options: { title: 'Google signin', headerShown: false },
link: '/logged/google',
},
} as const);
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -4,9 +4,15 @@ import { useEffect } from 'react';
const ThemeProvider = ({ children }: { children: JSX.Element }) => {
const colorScheme = useColorScheme();
const config = {
dependencies: {
"linear-gradient": require("expo-linear-gradient").LinearGradient,
},
};
return (
<NativeBaseProvider
config={config}
theme={extendTheme({
config: {
useSystemColorMode: false,

View File

@@ -32,6 +32,14 @@
"eas": {
"projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2"
}
}
},
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you set your personal avatar."
}
]
]
}
}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { translate } from '../i18n/i18n';
import { Box, Text, VStack, Progress, Stack, AspectRatio } from 'native-base';
import { Box, Text, VStack, Progress, Stack } from 'native-base';
import { useNavigation } from '../Navigation';
import { Image } from 'native-base';
import Card from '../components/Card';
import UserAvatar from './UserAvatar';
const ProgressBar = ({ xp }: { xp: number }) => {
const level = Math.floor(xp / 1000);
@@ -15,18 +15,8 @@ const ProgressBar = ({ xp }: { xp: number }) => {
return (
<Card w="100%" onPress={() => nav.navigate('User')}>
<Stack padding={4} space={2} direction="row">
<AspectRatio ratio={1}>
<Image
position="relative"
borderRadius={100}
source={{
uri: 'https://wallpaperaccess.com/full/317501.jpg', // TODO : put the actual profile pic
}}
alt="Profile picture"
zIndex={0}
/>
</AspectRatio>
<Stack padding={4} space={2} direction="row" alignItems="center">
<UserAvatar />
<VStack alignItems={'center'} flexGrow={1} space={2}>
<Text>{`${translate('level')} ${level}`}</Text>
<Box w="100%">

View File

@@ -0,0 +1,34 @@
import { useColorScheme } from 'react-native';
import { RootState, useSelector } from '../state/Store';
import { Box, Pressable } from 'native-base';
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>
);
};
export default RowCustom;

View File

@@ -0,0 +1,78 @@
import { Box, useBreakpointValue, useTheme } from 'native-base';
import { LineChart } from 'react-native-chart-kit';
import { CardBorderRadius } from './Card';
import SongHistory from '../models/SongHistory';
import { useState } from 'react';
type ScoreGraphProps = {
// The result of the call to API.getSongHistory
songHistory: SongHistory;
};
const formatScoreDate = (playDate: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
const formattedDate = `${pad(playDate.getDay())}/${pad(playDate.getMonth())}`;
const formattedTime = `${pad(playDate.getHours())}:${pad(playDate.getMinutes())}`;
return `${formattedDate} ${formattedTime}`;
};
const ScoreGraph = (props: ScoreGraphProps) => {
const theme = useTheme();
const [containerWidth, setContainerWidth] = useState(0);
// We sort the scores by date, asc.
// By default, the API returns them in desc.
// const pointsToDisplay = props.width / 100;
const isSmall = useBreakpointValue({ base: true, md: false });
const scores = props.songHistory.history
.sort((a, b) => {
if (a.playDate < b.playDate) {
return -1;
} else if (a.playDate > b.playDate) {
return 1;
}
return 0;
})
.slice(-10);
return (
<Box
bgColor={theme.colors.primary[500]}
style={{ width: '100%', borderRadius: CardBorderRadius }}
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
>
<LineChart
data={{
labels: isSmall ? [] : scores.map(({ playDate }) => formatScoreDate(playDate)),
datasets: [
{
data: scores.map(({ score }) => score),
},
],
}}
width={containerWidth}
height={200} // Completely arbitrary
transparent={true}
yAxisSuffix=" pts"
chartConfig={{
decimalPlaces: 0,
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
labelColor: () => theme.colors.white,
propsForDots: {
r: '6',
strokeWidth: '2',
},
}}
bezier
style={{
margin: 3,
shadowColor: theme.colors.primary[400],
shadowOpacity: 1,
shadowRadius: 20,
borderRadius: CardBorderRadius,
}}
/>
</Box>
);
};
export default ScoreGraph;

View File

@@ -5,7 +5,7 @@ import { translate } from '../i18n/i18n';
import { SearchContext } from '../views/SearchView';
import { debounce } from 'lodash';
export type Filter = 'artist' | 'song' | 'genre' | 'all';
export type Filter = 'artist' | 'song' | 'genre' | 'all' | 'favorite';
type FilterButton = {
name: string;
@@ -16,7 +16,7 @@ type FilterButton = {
const SearchBar = () => {
const { filter, updateFilter } = React.useContext(SearchContext);
const { stringQuery, updateStringQuery } = React.useContext(SearchContext);
const [barText, updateBarText] = React.useState(stringQuery);
const [ barText, updateBarText ] = React.useState(stringQuery);
const debouncedUpdateStringQuery = debounce(updateStringQuery, 500);
@@ -42,6 +42,11 @@ const SearchBar = () => {
callback: () => updateFilter('all'),
id: 'all',
},
{
name: translate('favoriteFilter'),
callback: () => updateFilter('favorite'),
id: 'favorite',
},
{
name: translate('artistFilter'),
callback: () => updateFilter('artist'),

View File

@@ -29,6 +29,8 @@ import SearchHistoryCard from './HistoryCard';
import Song, { SongWithArtist } from '../models/Song';
import { useNavigation } from '../Navigation';
import Artist from '../models/Artist';
import SongRow from '../components/SongRow';
const swaToSongCardProps = (song: SongWithArtist) => ({
songId: song.id,
@@ -66,67 +68,67 @@ const RowCustom = (props: Parameters<typeof Box>[0] & { onPress?: () => void })
);
};
type SongRowProps = {
song: Song | SongWithArtist; // TODO: remove Song
onPress: () => void;
};
// 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 }}
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>
);
};
// 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 }}
// 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: () => {},
@@ -252,13 +254,13 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
</Text>
{artistData?.length ? (
<CardGridCustom
content={artistData.slice(0, props.maxItems ?? artistData.length).map((a) => ({
image: API.getArtistIllustration(a.id),
name: a.name,
id: a.id,
content={artistData.slice(0, props.maxItems ?? artistData.length).map((artistData) => ({
image: API.getArtistIllustration(artistData.id),
name: artistData.name,
id: artistData.id,
onPress: () => {
API.createSearchHistoryEntry(a.name, 'artist');
navigation.navigate('Artist', { artistId: a.id });
API.createSearchHistoryEntry(artistData.name, 'artist');
navigation.navigate('Artist', { artistId: artistData.id });
},
}))}
cardComponent={ArtistCard}
@@ -287,7 +289,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
id: g.id,
onPress: () => {
API.createSearchHistoryEntry(g.name, 'genre');
navigation.navigate('Home');
navigation.navigate('Genre', {genreId: g.id});
},
}))}
cardComponent={GenreCard}
@@ -299,6 +301,35 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
);
};
const FavoriteSearchComponent = (props: SongsSearchComponentProps) => {
const { favoriteData } = React.useContext(SearchContext);
const navigation = useNavigation();
return (
<Box>
<Text fontSize="xl" fontWeight="bold" mt={4}>
{translate('favoriteFilter')}
</Text>
<Box>
{favoriteData?.length ? (
favoriteData.slice(0, props.maxRows).map((comp, index) => (
<SongRow
key={index}
song={comp}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');
navigation.navigate('Song', { songId: comp.id });
}}
/>
))
) : (
<Text>{translate('errNoResults')}</Text>
)}
</Box>
</Box>
)
}
const AllComponent = () => {
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
@@ -344,6 +375,8 @@ const FilterSwitch = () => {
return <ArtistSearchComponent />;
case 'genre':
return <GenreSearchComponent />;
case 'favorite':
return <FavoriteSearchComponent />;
default:
return <Text>Something very bad happened: {currentFilter}</Text>;
}
@@ -351,7 +384,8 @@ const FilterSwitch = () => {
export const SearchResultComponent = () => {
const { stringQuery } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim();
const { filter } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim() || filter == "favorite";
return shouldOutput ? (
<Box p={5}>

View File

@@ -0,0 +1,81 @@
import { HStack, IconButton, Image, Text } from "native-base";
import Song, { SongWithArtist } from "../models/Song";
import RowCustom from "./RowCustom";
import TextButton from "./TextButton";
import { MaterialIcons } from "@expo/vector-icons";
import API from "../API";
type SongRowProps = {
liked: boolean;
song: Song | SongWithArtist; // TODO: remove Song
onPress: () => void;
};
const handleLikeButton = {
}
const SongRow = ({ song, onPress, liked }: 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 }}
alt={song.name}
borderColor={'white'}
borderWidth={1}
/>
<IconButton size={'sm'} variant="ghost" _icon={{
as: MaterialIcons,
name: !liked ? "favorite-outline" : 'favorite',
}} />
<HStack
style={{
display: 'flex',
flexShrink: 1,
flexGrow: 1,
alignItems: 'center',
justifyContent: 'flex-start',
}}
space={6}
>
<Text
style={{
flexShrink: 1,
}}
isTruncated
pl={5}
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"
mr={5}
onPress={onPress}
/>
</HStack>
</RowCustom>
);
};
export default SongRow;

View File

@@ -0,0 +1,38 @@
import { Avatar } from 'native-base';
import API from '../API';
import { useQuery } from '../Queries';
import { useMemo } from 'react';
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('');
};
type UserAvatarProps = Pick<Parameters<typeof Avatar>[0], 'size'>;
const UserAvatar = ({ size }: UserAvatarProps) => {
const user = useQuery(API.getUserInfo);
const avatarUrl = useMemo(() => {
if (!user.data) {
return null;
}
const url = new URL(user.data.data.avatar);
url.searchParams.append('updatedAt', user.dataUpdatedAt.toString());
return url;
}, [user.data]);
return (
<Avatar
size={size}
source={avatarUrl ? { uri: avatarUrl.toString() } : undefined}
style={{ zIndex: 0 }}
>
{user.data !== undefined && getInitials(user.data.name)}
</Avatar>
);
};
export default UserAvatar;

View File

@@ -7,6 +7,7 @@ export const en = {
signOutBtn: 'Sign out',
signInBtn: 'Sign in',
signUpBtn: 'Sign up',
continuewithgoogle: 'Continue with Google',
changeLanguageBtn: 'Change language',
search: 'Search',
login: 'Login',
@@ -42,6 +43,7 @@ export const en = {
artistFilter: 'Artists',
songsFilter: 'Songs',
genreFilter: 'Genres',
favoriteFilter: 'Favorites',
// profile page
user: 'Profile',
@@ -179,6 +181,8 @@ export const en = {
recentSearches: 'Recent searches',
noRecentSearches: 'No recent searches',
avatar: 'Avatar',
changeIt: 'Change It',
};
export const fr: typeof en = {
@@ -189,6 +193,7 @@ export const fr: typeof en = {
welcomeMessage: 'Re-Bonjour ',
signOutBtn: 'Se déconnecter',
signInBtn: 'Se connecter',
continuewithgoogle: 'Continuer avec Google',
changeLanguageBtn: 'Changer la langue',
searchBtn: 'Rechercher',
playBtn: 'Jouer',
@@ -227,6 +232,7 @@ export const fr: typeof en = {
artistFilter: 'Artistes',
songsFilter: 'Morceaux',
genreFilter: 'Genres',
favoriteFilter: 'Favoris',
// Difficulty settings
diffBtn: 'Difficulté',
@@ -360,6 +366,8 @@ export const fr: typeof en = {
recentSearches: 'Recherches récentes',
noRecentSearches: 'Aucune recherche récente',
avatar: 'Avatar',
changeIt: 'Modifier',
};
export const sp: typeof en = {
@@ -422,6 +430,7 @@ export const sp: typeof en = {
artistFilter: 'Artistas',
songsFilter: 'canciones',
genreFilter: 'géneros',
favoriteFilter: 'Favorites',
// Difficulty settings
diffBtn: 'Dificultad',
@@ -545,4 +554,8 @@ export const sp: typeof en = {
recentSearches: 'Búsquedas recientes',
noRecentSearches: 'No hay búsquedas recientes',
continuewithgoogle: 'Continuar con Google',
avatar: 'Avatar',
changeIt: 'Cambialo',
};

View File

@@ -5,6 +5,7 @@ export const SongHistoryItemValidator = yup.object({
songID: yup.number().required(),
userID: yup.number().required(),
score: yup.number().required(),
playDate: yup.date().required(),
difficulties: yup.mixed().required(),
});
@@ -38,6 +39,7 @@ export type SongHistoryItem = {
songID: number;
userID: number;
score: number;
playDate: Date;
difficulties: object;
};

View File

@@ -1,12 +1,14 @@
import Model, { ModelValidator } from './Model';
import * as yup from 'yup';
import ResponseHandler from './ResponseHandler';
import API from '../API';
export const UserValidator = yup
.object({
username: yup.string().required(),
password: yup.string().required(),
password: yup.string().required().nullable(),
email: yup.string().required(),
googleID: yup.string().required().nullable(),
isGuest: yup.boolean().required(),
partyPlayed: yup.number().required(),
})
@@ -22,7 +24,7 @@ export const UserHandler: ResponseHandler<yup.InferType<typeof UserValidator>, U
gamesPlayed: value.partyPlayed as number,
xp: 0,
createdAt: new Date('2023-04-09T00:00:00.000Z'),
avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn',
avatar: `${API.baseUrl}/users/${value.id}/picture`,
},
}),
};
@@ -30,6 +32,7 @@ export const UserHandler: ResponseHandler<yup.InferType<typeof UserValidator>, U
interface User extends Model {
name: string;
email: string;
googleID: string | null;
isGuest: boolean;
premium: boolean;
data: UserData;
@@ -38,7 +41,7 @@ interface User extends Model {
interface UserData {
gamesPlayed: number;
xp: number;
avatar: string | undefined;
avatar: string;
createdAt: Date;
}

View File

@@ -22,4 +22,4 @@ server {
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
}
}
}

View File

@@ -34,11 +34,17 @@
"expo": "^47.0.8",
"expo-asset": "~8.7.0",
"expo-dev-client": "~2.0.1",
<<<<<<< HEAD
"expo-linear-gradient": "^12.3.0",
=======
"expo-image-picker": "~14.0.2",
>>>>>>> main
"expo-linking": "~3.3.1",
"expo-screen-orientation": "~5.0.1",
"expo-secure-store": "~12.0.0",
"expo-splash-screen": "~0.17.5",
"expo-status-bar": "~1.4.2",
"file64": "^1.0.2",
"format-duration": "^2.0.0",
"i18next": "^21.8.16",
"install": "^0.13.0",
@@ -52,12 +58,13 @@
"react-dom": "18.1.0",
"react-i18next": "^11.18.3",
"react-native": "0.70.5",
"react-native-chart-kit": "^6.12.0",
"react-native-paper": "^4.12.5",
"react-native-reanimated": "~2.12.0",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "~3.18.0",
"react-native-super-grid": "^4.6.1",
"react-native-svg": "13.4.0",
"react-native-svg": "^13.10.0",
"react-native-testing-library": "^6.0.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-web": "~0.18.7",

View File

@@ -1,49 +1,64 @@
import { VStack, Image, Heading, IconButton, Icon, Container } from 'native-base';
import { VStack, Text, Box, Image, Heading, IconButton, Icon, Container, Center, useBreakpointValue, ScrollView } from 'native-base';
import { Ionicons } from '@expo/vector-icons';
// import { Box, Image, Heading, useBreakpointValue } from 'native-base';
import { SafeAreaView } from 'react-native';
import { useQuery } from '../Queries';
import { LoadingView } from '../components/Loading';
import API from '../API';
import { useNavigation } from '../Navigation';
const handleFavorite = () => {};
import Song, { SongWithArtist } from '../models/Song';
import SongRow from '../components/SongRow';
import { Key } from 'react';
import { RouteProps, useNavigation } from '../Navigation';
import { ImageBackground } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
type ArtistDetailsViewProps = {
artistId: number;
};
const ArtistDetailsView = ({ artistId }: ArtistDetailsViewProps) => {
const ArtistDetailsView = ({ artistId }: RouteProps<ArtistDetailsViewProps>) => {
const artistQuery = useQuery(API.getArtist(artistId));
const songsQuery = useQuery(API.getSongsByArtist(artistId));
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
const isMobileView = screenSize == 'small';
const navigation = useNavigation();
const { isLoading, data: artistData, isError } = useQuery(API.getArtist(artistId));
if (isLoading) {
if (artistQuery.isError || songsQuery.isError) {
navigation.navigate('Error');
return <></>;
}
if (!artistQuery.data || songsQuery.data === undefined) {
return <LoadingView />;
}
if (isError) {
navigation.navigate('Error');
}
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>
<ScrollView>
<ImageBackground
style={{width : '100%', height: isMobileView ? 200 : 300}}
source={{uri : "https://picsum.photos/720"}}>
<LinearGradient
colors={['#00000000', '#000000']}
style={{height : '100%', width : '100%'}}/>
</ImageBackground>
<Box>
<Heading mt={-20} ml={3} fontSize={50}>{artistQuery.data.name}</Heading>
<ScrollView mt={3}>
<Box>
{songsQuery.data.map((comp: Song, index: Key | null | undefined) => (
<SongRow
key={index}
song={comp}
liked={true}
onPress={() => {
API.createSearchHistoryEntry(comp.name, 'song');
navigation.navigate('Song', { songId: comp.id });
}}
/>
))}
</Box>
</ScrollView>
</Box>
</ScrollView>
);
};

View File

@@ -8,6 +8,7 @@ import SigninForm from '../components/forms/signinform';
import SignupForm from '../components/forms/signupform';
import TextButton from '../components/TextButton';
import { RouteProps, useNavigation } from '../Navigation';
import * as Linking from 'expo-linking';
const hanldeSignin = async (
username: string,
@@ -56,6 +57,13 @@ const AuthenticationView = ({ isSignup }: RouteProps<AuthenticationViewProps>) =
<Text>
<Translate translationKey="welcome" />
</Text>
<TextButton
translate={{ translationKey: 'continuewithgoogle' }}
variant="outline"
marginTop={5}
colorScheme="primary"
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
/>
{mode === 'signin' ? (
<SigninForm
onSubmit={(username, password) =>

View File

@@ -0,0 +1,117 @@
import { SafeAreaView } from 'react-native';
import { VStack, Text, Box, Flex, Image, Heading, IconButton, Icon, Container, Center, useBreakpointValue, ScrollView } from 'native-base';
import { useQuery } from '../Queries';
import { LoadingView } from '../components/Loading';
import { useNavigation } from '../Navigation';
import API from '../API';
import Artist from '../models/Artist';
import ArtistCard from '../components/ArtistCard';
import CardGridCustom from '../components/CardGridCustom';
import { translate } from '../i18n/i18n';
const colorRange = [
{
code: '#364fc7',
},
{
code: '#5c940d',
},
{
code: '#c92a2a',
},
{
code: '#d6336c',
},
{
code: '#20c997'
}
]
const rockArtists: Artist[] = [
{
id: 1,
name: "Led Zeppelin",
picture: "https://picsum.photos/200",
},
{
id: 2,
name: "Queen",
picture: "https://picsum.photos/200",
},
{
id: 3,
name: "The Rolling Stones",
picture: "https://picsum.photos/200",
},
{
id: 4,
name: "AC/DC",
picture: "https://picsum.photos/200",
},
{
name: "Guns N' Roses",
id: 5,
picture: "https://picsum.photos/200",
},
];
const GenreDetailsView = ({ genreId }: any) => {
const { isLoading: isLoadingGenre, data: genreData, error: isErrorGenre } = useQuery(API.getArtist(genreId));
const screenSize = useBreakpointValue({ base: "small", md: "big" });
const isMobileView = screenSize == "small";
const navigation = useNavigation();
// if (isLoadingGenre) {
// return <LoadingView />;
// }
// if (isErrorGenre) {
// navigation.navigate('Error');
// }
return (
<ScrollView>
<Box
size={'100%'}
height={isMobileView ? 200 : 300}
width={'100%'}
bg={{
linearGradient: {
colors: [colorRange[Math.floor(Math.random() * 5)]?.code ?? '#364fc7', 'black'],
start: [0, 0],
end: [0, 1],
},}}
/>
<Flex
flexWrap="wrap"
direction={isMobileView ? 'column' : 'row'}
justifyContent={['flex-start']}
mt={4}
>
<Box>
{rockArtists?.length ? (
<CardGridCustom
content={rockArtists.slice(0, rockArtists.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}
/>
) : (
<Text>{translate('errNoResults')}</Text>
)}
</Box>
<Box>
</Box>
</Flex>
</ScrollView>
);
}
export default GenreDetailsView;

View File

@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import API from '../API';
import { setAccessToken } from '../state/UserSlice';
import { Text } from 'native-base';
import { useRoute } from '@react-navigation/native';
import { AccessTokenResponseHandler } from '../models/AccessTokenResponse';
const GoogleView = () => {
const dispatch = useDispatch();
const route = useRoute();
useEffect(() => {
const params = route.path?.replace('/logged/google', '');
async function run() {
const accessToken = await API.fetch(
{
route: `/auth/logged/google${params}`,
method: 'GET',
},
{ handler: AccessTokenResponseHandler }
).then((responseBody) => responseBody.access_token);
dispatch(setAccessToken(accessToken));
}
run();
}, []);
return <Text>Loading please wait</Text>;
};
export default GoogleView;

View File

@@ -1,81 +1,11 @@
import React from 'react';
import { Dimensions, View } from 'react-native';
import { Box, Image, Heading, HStack, Card, Text } from 'native-base';
import Translate from '../components/Translate';
import { Box, Image, Heading, HStack } from 'native-base';
import { useNavigation } from '../Navigation';
import TextButton from '../components/TextButton';
const UserMedals = () => {
return (
<Card marginX={20} marginY={10}>
<Heading>
<Translate translationKey="medals" />
</Heading>
<HStack alignItems={'row'} space="10">
<Image
source={{
uri: 'https://wallpaperaccess.com/full/317501.jpg',
}}
alt="Profile picture"
size="lg"
/>
<Image
source={{
uri: 'https://wallpaperaccess.com/full/317501.jpg',
}}
alt="Profile picture"
size="lg"
/>
<Image
source={{
uri: 'https://wallpaperaccess.com/full/317501.jpg',
}}
alt="Profile picture"
size="lg"
/>
<Image
source={{
uri: 'https://wallpaperaccess.com/full/317501.jpg',
}}
alt="Profile picture"
size="lg"
/>
</HStack>
</Card>
);
};
const PlayerStats = () => {
const answer = 'Answer from back';
return (
<Card marginX={20} marginY={10}>
<Heading>
{' '}
<Translate translationKey="playerStats" />{' '}
</Heading>
<Text>
{' '}
<Translate translationKey="mostPlayedSong" /> {answer}{' '}
</Text>
<Text>
{' '}
<Translate translationKey="goodNotesPlayed" /> {answer}{' '}
</Text>
<Text>
{' '}
<Translate translationKey="longestCombo" /> {answer}{' '}
</Text>
<Text>
{' '}
<Translate translationKey="favoriteGenre" /> {answer}{' '}
</Text>
</Card>
);
};
import UserAvatar from '../components/UserAvatar';
const ProfilePictureBannerAndLevel = () => {
const profilePic = 'https://wallpaperaccess.com/full/317501.jpg';
const username = 'Username';
const level = '1';
@@ -93,19 +23,13 @@ const ProfilePictureBannerAndLevel = () => {
size="lg"
style={{ height: imageHeight, width: imageWidth, zIndex: 0, opacity: 0.5 }}
/>
<Box zIndex={1} position={'absolute'} marginY={10} marginX={10}>
<Image
borderRadius={100}
source={{ uri: profilePic }}
alt="Profile picture"
size="lg"
style={{ position: 'absolute' }}
/>
<Box w="100%" paddingY={3} paddingLeft={100}>
<HStack zIndex={1} space={3} position={'absolute'} marginY={10} marginX={10}>
<UserAvatar size="lg" />
<Box>
<Heading>{username}</Heading>
<Heading>Level : {level}</Heading>
</Box>
</Box>
</HStack>
</View>
);
};
@@ -116,8 +40,6 @@ const ProfileView = () => {
return (
<View style={{ flexDirection: 'column' }}>
<ProfilePictureBannerAndLevel />
<UserMedals />
<PlayerStats />
<Box w="10%" paddingY={10} paddingLeft={5} paddingRight={50} zIndex={1}>
<TextButton
onPress={() => navigation.navigate('Settings', { screen: 'profile' })}

View File

@@ -8,6 +8,7 @@ import CardGridCustom from '../components/CardGridCustom';
import SongCard from '../components/SongCard';
import { useQueries, useQuery } from '../Queries';
import { LoadingView } from '../components/Loading';
import ScoreGraph from '../components/ScoreGraph';
type ScoreViewProps = {
songId: number;
@@ -32,6 +33,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
const artistQuery = useQuery(() => API.getArtist(songQuery.data!.artistId!), {
enabled: songQuery.data !== undefined,
});
const scoresQuery = useQuery(API.getSongHistory(props.songId), { refetchOnWindowFocus: true });
const recommendations = useQuery(API.getSongSuggestions);
const artistRecommendations = useQueries(
recommendations.data
@@ -54,7 +56,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
return (
<ScrollView p={8} contentContainerStyle={{ alignItems: 'center' }}>
<VStack width={{ base: '100%', lg: '50%' }} textAlign="center">
<VStack width={{ base: '100%', lg: '50%' }} space={3} textAlign="center">
<Text bold fontSize="lg">
{songQuery.data.name}
</Text>
@@ -137,6 +139,9 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
</Column>
</Card>
</Row>
{scoresQuery.data && (scoresQuery.data?.history?.length ?? 0) > 1 && (
<ScoreGraph songHistory={scoresQuery.data} />
)}
<CardGridCustom
style={{ justifyContent: 'space-evenly' }}
content={recommendations.data.map((i) => ({

View File

@@ -12,8 +12,8 @@ import { ScrollView } from 'native-base';
import { RouteProps } from '../Navigation';
interface SearchContextType {
filter: 'artist' | 'song' | 'genre' | 'all';
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all') => void;
filter: 'artist' | 'song' | 'genre' | 'all' | 'favorite';
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all' | 'favorite') => void;
stringQuery: string;
updateStringQuery: (newData: string) => void;
songData: Song[];

View File

@@ -1,13 +1,12 @@
import { Divider, Box, Image, Text, VStack, PresenceTransition, Icon, Stack } from 'native-base';
import { Box, Image, Text, Icon, Stack } from 'native-base';
import { useQuery } from '../Queries';
import LoadingComponent, { LoadingView } from '../components/Loading';
import React, { useEffect, useState } from 'react';
import { Translate, translate } from '../i18n/i18n';
import formatDuration from 'format-duration';
import { LoadingView } from '../components/Loading';
import { Translate } from '../i18n/i18n';
import { Ionicons } from '@expo/vector-icons';
import API from '../API';
import TextButton from '../components/TextButton';
import { RouteProps, useNavigation } from '../Navigation';
import ScoreGraph from '../components/ScoreGraph';
interface SongLobbyProps {
// The unique identifier to find a song
@@ -15,6 +14,7 @@ interface SongLobbyProps {
}
const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
const rootComponentPadding = 30;
const navigation = useNavigation();
// Refetch to update score when coming back from score view
const songQuery = useQuery(API.getSong(props.songId), { refetchOnWindowFocus: true });
@@ -22,18 +22,13 @@ const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
refetchOnWindowFocus: true,
});
const scoresQuery = useQuery(API.getSongHistory(props.songId), { refetchOnWindowFocus: true });
const [chaptersOpen, setChaptersOpen] = useState(false);
useEffect(() => {
if (chaptersOpen && !chaptersQuery.data) chaptersQuery.refetch();
}, [chaptersOpen]);
useEffect(() => {}, [songQuery.isLoading]);
if (songQuery.isLoading || scoresQuery.isLoading) return <LoadingView />;
if (songQuery.isError || scoresQuery.isError) {
navigation.navigate('Error');
return <></>;
}
return (
<Box style={{ padding: 30, flexDirection: 'column' }}>
<Box style={{ padding: rootComponentPadding, flexDirection: 'column' }}>
<Box style={{ flexDirection: 'row', height: '30%' }}>
<Box style={{ flex: 3 }}>
<Image
@@ -117,42 +112,9 @@ const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
<Text>{scoresQuery.data?.history.at(0)?.score ?? 0}</Text>
</Box>
</Box>
{/* <Text style={{ paddingBottom: 10 }}>{songQuery.data!.description}</Text> */}
<Box flexDirection="row">
<TextButton
translate={{ translationKey: 'chapters' }}
variant="ghost"
onPress={() => setChaptersOpen(!chaptersOpen)}
endIcon={
<Icon
as={Ionicons}
name={chaptersOpen ? 'chevron-up-outline' : 'chevron-down-outline'}
/>
}
/>
</Box>
<PresenceTransition visible={chaptersOpen} initial={{ opacity: 0 }}>
{chaptersQuery.isLoading && <LoadingComponent />}
{!chaptersQuery.isLoading && (
<VStack flex={1} space={4} padding="4" divider={<Divider />}>
{chaptersQuery.data!.map((chapter) => (
<Box
key={chapter.id}
flexGrow={1}
flexDirection="row"
justifyContent="space-between"
>
<Text>{chapter.name}</Text>
<Text>
{`${translate('level')} ${
chapter.difficulty
} - ${formatDuration((chapter.end - chapter.start) * 1000)}`}
</Text>
</Box>
))}
</VStack>
)}
</PresenceTransition>
{scoresQuery.data && (scoresQuery.data?.history?.length ?? 0) > 0 && (
<ScoreGraph songHistory={scoresQuery.data} />
)}
</Box>
);
};

View File

@@ -2,19 +2,14 @@ import API from '../../API';
import { useDispatch } from 'react-redux';
import { unsetAccessToken } from '../../state/UserSlice';
import React from 'react';
import { Column, Text, Button, Box, Flex, Center, Heading, Avatar, Popover } from 'native-base';
import { Column, Text, Button, Box, Flex, Center, Heading, Popover, Toast } from 'native-base';
import TextButton from '../../components/TextButton';
import { LoadingView } from '../../components/Loading';
import ElementList from '../../components/GtkUI/ElementList';
import { translate } from '../../i18n/i18n';
import { useQuery } from '../../Queries';
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('');
};
import UserAvatar from '../../components/UserAvatar';
import * as ImagePicker from 'expo-image-picker';
// Too painful to infer the settings-only, typed navigator. Gave up
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -41,9 +36,7 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
}}
>
<Center>
<Avatar size="2xl" source={{ uri: user.data.avatar }}>
{getInitials(user.name)}
</Avatar>
<UserAvatar size="2xl" />
</Center>
<ElementList
style={{
@@ -58,7 +51,39 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
data: {
text: user.email || translate('NoAssociatedEmail'),
onPress: () => {
navigation.navigate('ChangeEmail');
navigation.navigate('changeEmail');
},
},
},
{
type: 'text',
title: translate('avatar'),
data: {
text: translate('changeIt'),
onPress: () => {
ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
aspect: [1, 1],
quality: 1,
base64: true,
}).then((result) => {
console.log(result);
const image = result.assets?.at(0);
if (!result.canceled && image) {
API.updateProfileAvatar(image)
.then(() => {
userQuery.refetch();
Toast.show({
description: 'Update successful',
});
})
.catch((e) => {
console.error(e);
Toast.show({ description: 'Update failed' });
});
}
});
},
},
},
@@ -87,6 +112,17 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
text: user.id.toString(),
},
},
{
type: 'text',
title: 'Google Account',
data: {
text: user.googleID ? 'Linked' : 'Not linked',
},
// type: 'custom',
// data: user.googleID
// ? <Button><Text>Unlink</Text></Button>
// : <Button><Text>Link</Text></Button>,
},
{
type: 'text',
title: translate('nbGamesPlayed'),

View File

@@ -9116,6 +9116,18 @@ expo-font@~11.0.1:
dependencies:
fontfaceobserver "^2.1.0"
expo-image-loader@~4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-4.0.0.tgz#a17e5f95a4c1671791168dd5dfc221bf2f88480c"
integrity sha512-hVMhXagsO1cSng5s70IEjuJAuHy2hX/inu5MM3T0ecJMf7L/7detKf22molQBRymerbk6Tzu+20h11eU0n/3jQ==
expo-image-picker@~14.0.2:
version "14.0.3"
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.0.3.tgz#ea0bbe796ccc3bd5e58fc00487be22bac317afeb"
integrity sha512-VN5wMWzhYhIRhFq8I1pjMbn/ivjlhWfxzJpz5jUOf3mQ8vxrI5GcR8cJO9kyYwuCrI9W3GUzh/aDt7QRSTQDDA==
dependencies:
expo-image-loader "~4.0.0"
expo-json-utils@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.4.0.tgz#47ae83a1cc973101d62371f94790e9ad39491751"
@@ -9126,6 +9138,11 @@ expo-keep-awake@~11.0.1:
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-11.0.1.tgz#ee354465892a94040ffe09901b85b469e7d54fb3"
integrity sha512-44ZjgLE4lnce2d40Pv8xsjMVc6R5GvgHOwZfkLYtGmgYG9TYrEJeEj5UfSeweXPL3pBFhXKfFU8xpGYMaHdP0A==
expo-linear-gradient@^12.3.0:
version "12.3.0"
resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-12.3.0.tgz#7abd8fedbf0138c86805aebbdfbbf5e5fa865f19"
integrity sha512-f9e+Oxe5z7fNQarTBZXilMyswlkbYWQHONVfq8MqmiEnW3h9XsxxmVJLG8uVQSQPUsbW+x1UUT/tnU6mkMWeLg==
expo-linking@~3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-3.3.1.tgz#253b183321e54cb6fa1a667a53d4594aa88a3357"
@@ -9459,6 +9476,11 @@ file-uri-to-path@1.0.0:
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
file64@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/file64/-/file64-1.0.2.tgz#d3dde9bab142ccf0049e0bd407a2576e94894825"
integrity sha512-cDQefGBdb8OO7Pb2nXiRcZlVjwgzoG0uuJ/H2fxNdz3vbOZctp0iPJoHDQ4VZrirqGYc9n/p9+ZqptLZrcSGRA==
filesize@6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
@@ -14716,6 +14738,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
paths-js@^0.4.10:
version "0.4.11"
resolved "https://registry.yarnpkg.com/paths-js/-/paths-js-0.4.11.tgz#b2a9d5f94ee9949aa8fee945f78a12abff44599e"
integrity sha512-3mqcLomDBXOo7Fo+UlaenG6f71bk1ZezPQy2JCmYHy2W2k5VKpP+Jbin9H0bjXynelTbglCqdFhSEkeIkKTYUA==
pbkdf2@^3.0.3:
version "3.1.2"
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075"
@@ -14839,6 +14866,11 @@ pnp-webpack-plugin@^1.5.0:
dependencies:
ts-pnp "^1.1.6"
point-in-polygon@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357"
integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==
polished@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1"
@@ -15747,6 +15779,15 @@ react-merge-refs@^1.0.0:
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"
integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==
react-native-chart-kit@^6.12.0:
version "6.12.0"
resolved "https://registry.yarnpkg.com/react-native-chart-kit/-/react-native-chart-kit-6.12.0.tgz#187a4987a668a85b7e93588c248ed2c33b3a06f6"
integrity sha512-nZLGyCFzZ7zmX0KjYeeSV1HKuPhl1wOMlTAqa0JhlyW62qV/1ZPXHgT8o9s8mkFaGxdqbspOeuaa6I9jUQDgnA==
dependencies:
lodash "^4.17.13"
paths-js "^0.4.10"
point-in-polygon "^1.0.1"
react-native-codegen@^0.70.6:
version "0.70.6"
resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.70.6.tgz#2ce17d1faad02ad4562345f8ee7cbe6397eda5cb"
@@ -15816,10 +15857,10 @@ react-native-super-grid@^4.6.1:
dependencies:
prop-types "^15.6.0"
react-native-svg@13.4.0:
version "13.4.0"
resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-13.4.0.tgz#82399ba0956c454144618aa581e2d748dd3f010a"
integrity sha512-B3TwK+H0+JuRhYPzF21AgqMt4fjhCwDZ9QUtwNstT5XcslJBXC0FoTkdZo8IEb1Sv4suSqhZwlAY6lwOv3tHag==
react-native-svg@^13.10.0:
version "13.10.0"
resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-13.10.0.tgz#d3c6222ea9cc1e21e2af0fd59dfbeafe7a3d0dc1"
integrity sha512-D/oYTmUi5nsA/2Nw4WYlF1UUi3vZqhpESpiEhpYCIFB/EMd6vz4A/uq3tIzZFcfa5z2oAdGSxRU1TaYr8IcPlQ==
dependencies:
css-select "^5.1.0"
css-tree "^1.1.3"