Compare commits
21 Commits
feat/cover
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa82f16e3 | ||
|
|
f96aedef8c | ||
|
|
a3676fabf8 | ||
|
|
9f542fc9dd | ||
| 930191569f | |||
| 74cd9c0df2 | |||
| d2642b4fb8 | |||
| ebcc48cc57 | |||
| 95b08935cc | |||
| 04487c9b24 | |||
|
|
20eb62d19b | ||
|
|
567d3250e2 | ||
| 4207d5ee50 | |||
|
|
509cc5b9f8 | ||
|
|
1b22dba9cd | ||
|
|
c0d9ee7ca6 | ||
|
|
27f7945289 | ||
|
|
3d76834f45 | ||
| ccc86895e2 | |||
| 279d16d59a | |||
| 04d288b844 |
@@ -7,4 +7,6 @@ JWT_SECRET=wow
|
|||||||
POSTGRES_DB=chromacase
|
POSTGRES_DB=chromacase
|
||||||
API_URL=http://localhost:80/api
|
API_URL=http://localhost:80/api
|
||||||
SCORO_URL=ws://localhost:6543
|
SCORO_URL=ws://localhost:6543
|
||||||
|
GOOGLE_CLIENT_ID=toto
|
||||||
|
GOOGLE_SECRET=tata
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
|
||||||
|
|||||||
14
.github/workflows/CI.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install
|
run: yarn install
|
||||||
|
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn tsc
|
run: yarn tsc
|
||||||
- name: Check Prettier
|
- name: Check Prettier
|
||||||
@@ -84,16 +84,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Copy env file to github secret env file
|
- name: Copy env file to github secret env file
|
||||||
run: |
|
run: cp .env.example .env
|
||||||
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
|
|
||||||
|
|
||||||
- name: Start the service
|
- name: Start the service
|
||||||
run: docker-compose up -d back db
|
run: docker-compose up -d back db
|
||||||
@@ -101,6 +92,7 @@ jobs:
|
|||||||
- name: Perform healthchecks
|
- name: Perform healthchecks
|
||||||
run: |
|
run: |
|
||||||
docker-compose ps -a
|
docker-compose ps -a
|
||||||
|
docker-compose logs
|
||||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
||||||
|
|
||||||
- name: Run scorometer tests
|
- name: Run scorometer tests
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
11092
back/package-lock.json
generated
@@ -21,51 +21,55 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^8.0.0",
|
"@nestjs/common": "^10.1.0",
|
||||||
"@nestjs/config": "^2.1.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/core": "^10.1.0",
|
||||||
"@nestjs/jwt": "^8.0.1",
|
"@nestjs/jwt": "^10.1.0",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/passport": "^8.2.2",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^10.1.0",
|
||||||
"@nestjs/swagger": "^5.2.1",
|
"@nestjs/swagger": "^7.1.2",
|
||||||
"@prisma/client": "^4.4.0",
|
"@prisma/client": "^5.0.0",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/passport": "^1.0.9",
|
"@types/passport": "^1.0.12",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.14.0",
|
||||||
"passport-jwt": "^4.0.0",
|
"node-fetch": "^2.6.12",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^5.0.1",
|
||||||
"rxjs": "^7.2.0",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^4.5.0"
|
"swagger-ui-express": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^8.0.0",
|
"@nestjs/cli": "^10.1.10",
|
||||||
"@nestjs/schematics": "^8.0.0",
|
"@nestjs/schematics": "^10.0.1",
|
||||||
"@nestjs/testing": "^8.0.0",
|
"@nestjs/testing": "^10.1.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "27.4.1",
|
"@types/jest": "29.5.3",
|
||||||
"@types/node": "^16.0.0",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/node": "^20.4.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
"@types/passport-google-oauth20": "^2.0.11",
|
||||||
"@typescript-eslint/parser": "^5.0.0",
|
"@types/supertest": "^2.0.12",
|
||||||
"eslint": "^8.0.1",
|
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"@typescript-eslint/parser": "^6.1.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint": "^8.45.0",
|
||||||
"jest": "^27.2.5",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"prettier": "^2.3.2",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"prisma": "^4.4.0",
|
"jest": "^29.6.1",
|
||||||
"source-map-support": "^0.5.20",
|
"prettier": "^3.0.0",
|
||||||
"supertest": "^6.1.3",
|
"prisma": "^5.0.0",
|
||||||
"ts-jest": "^27.0.3",
|
"source-map-support": "^0.5.21",
|
||||||
"ts-loader": "^9.2.3",
|
"supertest": "^6.3.3",
|
||||||
"ts-node": "^10.0.0",
|
"ts-jest": "^29.1.1",
|
||||||
"tsconfig-paths": "^3.10.1",
|
"ts-loader": "^9.4.4",
|
||||||
"typescript": "^4.3.5"
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
12
back/prisma/migrations/20230621090510_google/migration.sql
Normal 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");
|
||||||
@@ -12,8 +12,9 @@ datasource db {
|
|||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String?
|
||||||
email String
|
email String
|
||||||
|
googleID String? @unique
|
||||||
isGuest Boolean @default(false)
|
isGuest Boolean @default(false)
|
||||||
partyPlayed Int @default(0)
|
partyPlayed Int @default(0)
|
||||||
LessonHistory LessonHistory[]
|
LessonHistory LessonHistory[]
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Patch,
|
Patch,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Req,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
HttpStatus,
|
||||||
|
ParseFilePipeBuilder,
|
||||||
|
Response,
|
||||||
} 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';
|
||||||
@@ -32,6 +38,9 @@ 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 { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { writeFile } from 'fs';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -42,12 +51,27 @@ export class AuthController {
|
|||||||
private settingsService: SettingsService,
|
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')
|
@Post('register')
|
||||||
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);
|
||||||
await this.settingsService.createUserSetting(user.id);
|
await this.settingsService.createUserSetting(user.id);
|
||||||
} catch(e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
@@ -69,6 +93,40 @@ export class AuthController {
|
|||||||
return this.authService.login(user);
|
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)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||||
@@ -116,25 +174,28 @@ 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' })
|
||||||
@Patch('me/settings')
|
@Patch('me/settings')
|
||||||
udpateSettings(
|
udpateSettings(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
|
@Body() settingUserDto: UpdateSettingDto,
|
||||||
|
): Promise<Setting> {
|
||||||
return this.settingsService.updateUserSettings({
|
return this.settingsService.updateUserSettings({
|
||||||
where: { userId: +req.user.id},
|
where: { userId: +req.user.id },
|
||||||
data: settingUserDto,
|
data: settingUserDto,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@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')
|
||||||
async getSettings(@Request() req: any): Promise<Setting> {
|
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();
|
if (!result) throw new NotFoundException();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { SettingsModule } from 'src/settings/settings.module';
|
import { SettingsModule } from 'src/settings/settings.module';
|
||||||
|
import { GoogleStrategy } from './google.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,7 +26,7 @@ import { SettingsModule } from 'src/settings/settings.module';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class AuthService {
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<PayloadInterface | null> {
|
): Promise<PayloadInterface | null> {
|
||||||
const user = await this.userService.user({ username });
|
const user = await this.userService.user({ username });
|
||||||
if (user && bcrypt.compareSync(password, user.password)) {
|
if (user && user.password && bcrypt.compareSync(password, user.password)) {
|
||||||
return {
|
return {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
35
back/src/auth/google.strategy.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { PrismaService } from './prisma/prisma.service';
|
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const prismaService = app.get(PrismaService);
|
app.enableShutdownHooks();
|
||||||
await prismaService.enableShutdownHooks(app);
|
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Chromacase')
|
.setTitle('Chromacase')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
|||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableShutdownHooks(app: INestApplication) {
|
|
||||||
this.$on('beforeExit', async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { UsersService } from './users.service';
|
||||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { User } from 'src/models/user';
|
import { User } from 'src/models/user';
|
||||||
@@ -20,4 +20,9 @@ export class UsersController {
|
|||||||
if (!ret) throw new NotFoundException();
|
if (!ret) throw new NotFoundException();
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/picture')
|
||||||
|
async getPicture(@Response() res: any, @Param('id') id: number) {
|
||||||
|
return await this.usersService.getProfilePicture(+id, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
StreamableFile,
|
||||||
|
} 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 { randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { createReadStream, existsSync } from 'fs';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
@@ -34,7 +41,7 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
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({
|
return this.prisma.user.create({
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
@@ -72,4 +79,24 @@ export class UsersService {
|
|||||||
where,
|
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
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./back:/app
|
- ./back:/app
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
scorometer:
|
scorometer:
|
||||||
image: ghcr.io/chroma-case/scorometer:main
|
image: ghcr.io/chroma-case/scorometer:main
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./assets:/assets
|
- ./assets:/assets
|
||||||
|
- ./data:/data
|
||||||
scorometer:
|
scorometer:
|
||||||
build: ./scorometer
|
build: ./scorometer
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
29
front/API.ts
@@ -21,6 +21,8 @@ import { PlageHandler } from './models/Plage';
|
|||||||
import { ListHandler } from './models/List';
|
import { ListHandler } from './models/List';
|
||||||
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
|
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
import { base64ToBlob } from 'file64';
|
||||||
|
import { ImagePickerAsset } from 'expo-image-picker';
|
||||||
|
|
||||||
type AuthenticationInput = { username: string; password: string };
|
type AuthenticationInput = { username: string; password: string };
|
||||||
type RegistrationInput = AuthenticationInput & { email: string };
|
type RegistrationInput = AuthenticationInput & { email: string };
|
||||||
@@ -30,6 +32,7 @@ export type AccessToken = string;
|
|||||||
type FetchParams = {
|
type FetchParams = {
|
||||||
route: string;
|
route: string;
|
||||||
body?: object;
|
body?: object;
|
||||||
|
formData?: FormData;
|
||||||
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
|
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): Promise<void>;
|
||||||
public static async fetch(params: FetchParams, handle?: HandleParams) {
|
public static async fetch(params: FetchParams, handle?: HandleParams) {
|
||||||
const jwtToken = store.getState().user.accessToken;
|
const jwtToken = store.getState().user.accessToken;
|
||||||
const header = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
...(params.formData == undefined && { 'Content-Type': 'application/json' }),
|
||||||
|
...(jwtToken && { Authorization: `Bearer ${jwtToken}` }),
|
||||||
};
|
};
|
||||||
const response = await fetch(`${API.baseUrl}${params.route}`, {
|
const response = await fetch(`${API.baseUrl}${params.route}`, {
|
||||||
headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header,
|
headers: headers,
|
||||||
body: JSON.stringify(params.body),
|
body: params.formData ?? JSON.stringify(params.body),
|
||||||
method: params.method ?? 'GET',
|
method: params.method ?? 'GET',
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
throw new Error('Error while fetching API: ' + API.baseUrl);
|
throw new Error('Error while fetching API: ' + API.baseUrl);
|
||||||
});
|
});
|
||||||
if (!handle || handle.emptyResponse) {
|
if (!handle || handle.emptyResponse) {
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(await response.json());
|
||||||
|
throw new APIError(response.statusText, response.status);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handle.raw) {
|
if (handle.raw) {
|
||||||
@@ -164,6 +172,7 @@ export default class API {
|
|||||||
{
|
{
|
||||||
route: '/auth/guest',
|
route: '/auth/guest',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: undefined,
|
||||||
},
|
},
|
||||||
{ handler: AccessTokenResponseHandler }
|
{ handler: AccessTokenResponseHandler }
|
||||||
)
|
)
|
||||||
@@ -626,4 +635,16 @@ export default class API {
|
|||||||
{ handler: UserHandler }
|
{ 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import { Button, Center, VStack } from 'native-base';
|
|||||||
import { unsetAccessToken } from './state/UserSlice';
|
import { unsetAccessToken } from './state/UserSlice';
|
||||||
import TextButton from './components/TextButton';
|
import TextButton from './components/TextButton';
|
||||||
import ErrorView from './views/ErrorView';
|
import ErrorView from './views/ErrorView';
|
||||||
|
<<<<<<< HEAD
|
||||||
import GenreDetailsView from './views/GenreDetailsView';
|
import GenreDetailsView from './views/GenreDetailsView';
|
||||||
|
=======
|
||||||
|
import GoogleView from './views/GoogleView';
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
// Util function to hide route props in URL
|
// Util function to hide route props in URL
|
||||||
const removeMe = () => '';
|
const removeMe = () => '';
|
||||||
@@ -106,6 +110,11 @@ const publicRoutes = () =>
|
|||||||
options: { title: 'Oops', headerShown: false },
|
options: { title: 'Oops', headerShown: false },
|
||||||
link: undefined,
|
link: undefined,
|
||||||
},
|
},
|
||||||
|
Google: {
|
||||||
|
component: GoogleView,
|
||||||
|
options: { title: 'Google signin', headerShown: false },
|
||||||
|
link: '/logged/google',
|
||||||
|
},
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2"
|
"projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "The app accesses your photos to let you set your personal avatar."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { translate } from '../i18n/i18n';
|
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 { useNavigation } from '../Navigation';
|
||||||
import { Image } from 'native-base';
|
|
||||||
import Card from '../components/Card';
|
import Card from '../components/Card';
|
||||||
|
import UserAvatar from './UserAvatar';
|
||||||
|
|
||||||
const ProgressBar = ({ xp }: { xp: number }) => {
|
const ProgressBar = ({ xp }: { xp: number }) => {
|
||||||
const level = Math.floor(xp / 1000);
|
const level = Math.floor(xp / 1000);
|
||||||
@@ -15,18 +15,8 @@ const ProgressBar = ({ xp }: { xp: number }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card w="100%" onPress={() => nav.navigate('User')}>
|
<Card w="100%" onPress={() => nav.navigate('User')}>
|
||||||
<Stack padding={4} space={2} direction="row">
|
<Stack padding={4} space={2} direction="row" alignItems="center">
|
||||||
<AspectRatio ratio={1}>
|
<UserAvatar />
|
||||||
<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>
|
|
||||||
<VStack alignItems={'center'} flexGrow={1} space={2}>
|
<VStack alignItems={'center'} flexGrow={1} space={2}>
|
||||||
<Text>{`${translate('level')} ${level}`}</Text>
|
<Text>{`${translate('level')} ${level}`}</Text>
|
||||||
<Box w="100%">
|
<Box w="100%">
|
||||||
|
|||||||
78
front/components/ScoreGraph.tsx
Normal 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;
|
||||||
38
front/components/UserAvatar.tsx
Normal 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;
|
||||||
@@ -7,6 +7,7 @@ export const en = {
|
|||||||
signOutBtn: 'Sign out',
|
signOutBtn: 'Sign out',
|
||||||
signInBtn: 'Sign in',
|
signInBtn: 'Sign in',
|
||||||
signUpBtn: 'Sign up',
|
signUpBtn: 'Sign up',
|
||||||
|
continuewithgoogle: 'Continue with Google',
|
||||||
changeLanguageBtn: 'Change language',
|
changeLanguageBtn: 'Change language',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
@@ -180,6 +181,8 @@ export const en = {
|
|||||||
|
|
||||||
recentSearches: 'Recent searches',
|
recentSearches: 'Recent searches',
|
||||||
noRecentSearches: 'No recent searches',
|
noRecentSearches: 'No recent searches',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
changeIt: 'Change It',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fr: typeof en = {
|
export const fr: typeof en = {
|
||||||
@@ -190,6 +193,7 @@ export const fr: typeof en = {
|
|||||||
welcomeMessage: 'Re-Bonjour ',
|
welcomeMessage: 'Re-Bonjour ',
|
||||||
signOutBtn: 'Se déconnecter',
|
signOutBtn: 'Se déconnecter',
|
||||||
signInBtn: 'Se connecter',
|
signInBtn: 'Se connecter',
|
||||||
|
continuewithgoogle: 'Continuer avec Google',
|
||||||
changeLanguageBtn: 'Changer la langue',
|
changeLanguageBtn: 'Changer la langue',
|
||||||
searchBtn: 'Rechercher',
|
searchBtn: 'Rechercher',
|
||||||
playBtn: 'Jouer',
|
playBtn: 'Jouer',
|
||||||
@@ -362,6 +366,8 @@ export const fr: typeof en = {
|
|||||||
|
|
||||||
recentSearches: 'Recherches récentes',
|
recentSearches: 'Recherches récentes',
|
||||||
noRecentSearches: 'Aucune recherche récente',
|
noRecentSearches: 'Aucune recherche récente',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
changeIt: 'Modifier',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sp: typeof en = {
|
export const sp: typeof en = {
|
||||||
@@ -548,4 +554,8 @@ export const sp: typeof en = {
|
|||||||
|
|
||||||
recentSearches: 'Búsquedas recientes',
|
recentSearches: 'Búsquedas recientes',
|
||||||
noRecentSearches: 'No hay búsquedas recientes',
|
noRecentSearches: 'No hay búsquedas recientes',
|
||||||
|
continuewithgoogle: 'Continuar con Google',
|
||||||
|
|
||||||
|
avatar: 'Avatar',
|
||||||
|
changeIt: 'Cambialo',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const SongHistoryItemValidator = yup.object({
|
|||||||
songID: yup.number().required(),
|
songID: yup.number().required(),
|
||||||
userID: yup.number().required(),
|
userID: yup.number().required(),
|
||||||
score: yup.number().required(),
|
score: yup.number().required(),
|
||||||
|
playDate: yup.date().required(),
|
||||||
difficulties: yup.mixed().required(),
|
difficulties: yup.mixed().required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export type SongHistoryItem = {
|
|||||||
songID: number;
|
songID: number;
|
||||||
userID: number;
|
userID: number;
|
||||||
score: number;
|
score: number;
|
||||||
|
playDate: Date;
|
||||||
difficulties: object;
|
difficulties: object;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import Model, { ModelValidator } from './Model';
|
import Model, { ModelValidator } from './Model';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
import ResponseHandler from './ResponseHandler';
|
import ResponseHandler from './ResponseHandler';
|
||||||
|
import API from '../API';
|
||||||
|
|
||||||
export const UserValidator = yup
|
export const UserValidator = yup
|
||||||
.object({
|
.object({
|
||||||
username: yup.string().required(),
|
username: yup.string().required(),
|
||||||
password: yup.string().required(),
|
password: yup.string().required().nullable(),
|
||||||
email: yup.string().required(),
|
email: yup.string().required(),
|
||||||
|
googleID: yup.string().required().nullable(),
|
||||||
isGuest: yup.boolean().required(),
|
isGuest: yup.boolean().required(),
|
||||||
partyPlayed: yup.number().required(),
|
partyPlayed: yup.number().required(),
|
||||||
})
|
})
|
||||||
@@ -22,7 +24,7 @@ export const UserHandler: ResponseHandler<yup.InferType<typeof UserValidator>, U
|
|||||||
gamesPlayed: value.partyPlayed as number,
|
gamesPlayed: value.partyPlayed as number,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
createdAt: new Date('2023-04-09T00:00:00.000Z'),
|
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 {
|
interface User extends Model {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
googleID: string | null;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
data: UserData;
|
data: UserData;
|
||||||
@@ -38,7 +41,7 @@ interface User extends Model {
|
|||||||
interface UserData {
|
interface UserData {
|
||||||
gamesPlayed: number;
|
gamesPlayed: number;
|
||||||
xp: number;
|
xp: number;
|
||||||
avatar: string | undefined;
|
avatar: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ server {
|
|||||||
proxy_set_header Connection $http_connection;
|
proxy_set_header Connection $http_connection;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,17 @@
|
|||||||
"expo": "^47.0.8",
|
"expo": "^47.0.8",
|
||||||
"expo-asset": "~8.7.0",
|
"expo-asset": "~8.7.0",
|
||||||
"expo-dev-client": "~2.0.1",
|
"expo-dev-client": "~2.0.1",
|
||||||
|
<<<<<<< HEAD
|
||||||
"expo-linear-gradient": "^12.3.0",
|
"expo-linear-gradient": "^12.3.0",
|
||||||
|
=======
|
||||||
|
"expo-image-picker": "~14.0.2",
|
||||||
|
>>>>>>> main
|
||||||
"expo-linking": "~3.3.1",
|
"expo-linking": "~3.3.1",
|
||||||
"expo-screen-orientation": "~5.0.1",
|
"expo-screen-orientation": "~5.0.1",
|
||||||
"expo-secure-store": "~12.0.0",
|
"expo-secure-store": "~12.0.0",
|
||||||
"expo-splash-screen": "~0.17.5",
|
"expo-splash-screen": "~0.17.5",
|
||||||
"expo-status-bar": "~1.4.2",
|
"expo-status-bar": "~1.4.2",
|
||||||
|
"file64": "^1.0.2",
|
||||||
"format-duration": "^2.0.0",
|
"format-duration": "^2.0.0",
|
||||||
"i18next": "^21.8.16",
|
"i18next": "^21.8.16",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
@@ -53,12 +58,13 @@
|
|||||||
"react-dom": "18.1.0",
|
"react-dom": "18.1.0",
|
||||||
"react-i18next": "^11.18.3",
|
"react-i18next": "^11.18.3",
|
||||||
"react-native": "0.70.5",
|
"react-native": "0.70.5",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
"react-native-paper": "^4.12.5",
|
"react-native-paper": "^4.12.5",
|
||||||
"react-native-reanimated": "~2.12.0",
|
"react-native-reanimated": "~2.12.0",
|
||||||
"react-native-safe-area-context": "4.4.1",
|
"react-native-safe-area-context": "4.4.1",
|
||||||
"react-native-screens": "~3.18.0",
|
"react-native-screens": "~3.18.0",
|
||||||
"react-native-super-grid": "^4.6.1",
|
"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-testing-library": "^6.0.0",
|
||||||
"react-native-url-polyfill": "^1.3.0",
|
"react-native-url-polyfill": "^1.3.0",
|
||||||
"react-native-web": "~0.18.7",
|
"react-native-web": "~0.18.7",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SigninForm from '../components/forms/signinform';
|
|||||||
import SignupForm from '../components/forms/signupform';
|
import SignupForm from '../components/forms/signupform';
|
||||||
import TextButton from '../components/TextButton';
|
import TextButton from '../components/TextButton';
|
||||||
import { RouteProps, useNavigation } from '../Navigation';
|
import { RouteProps, useNavigation } from '../Navigation';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
|
||||||
const hanldeSignin = async (
|
const hanldeSignin = async (
|
||||||
username: string,
|
username: string,
|
||||||
@@ -56,6 +57,13 @@ const AuthenticationView = ({ isSignup }: RouteProps<AuthenticationViewProps>) =
|
|||||||
<Text>
|
<Text>
|
||||||
<Translate translationKey="welcome" />
|
<Translate translationKey="welcome" />
|
||||||
</Text>
|
</Text>
|
||||||
|
<TextButton
|
||||||
|
translate={{ translationKey: 'continuewithgoogle' }}
|
||||||
|
variant="outline"
|
||||||
|
marginTop={5}
|
||||||
|
colorScheme="primary"
|
||||||
|
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
|
||||||
|
/>
|
||||||
{mode === 'signin' ? (
|
{mode === 'signin' ? (
|
||||||
<SigninForm
|
<SigninForm
|
||||||
onSubmit={(username, password) =>
|
onSubmit={(username, password) =>
|
||||||
|
|||||||
31
front/views/GoogleView.tsx
Normal 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;
|
||||||
@@ -1,81 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dimensions, View } from 'react-native';
|
import { Dimensions, View } from 'react-native';
|
||||||
import { Box, Image, Heading, HStack, Card, Text } from 'native-base';
|
import { Box, Image, Heading, HStack } from 'native-base';
|
||||||
import Translate from '../components/Translate';
|
|
||||||
import { useNavigation } from '../Navigation';
|
import { useNavigation } from '../Navigation';
|
||||||
import TextButton from '../components/TextButton';
|
import TextButton from '../components/TextButton';
|
||||||
|
import UserAvatar from '../components/UserAvatar';
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfilePictureBannerAndLevel = () => {
|
const ProfilePictureBannerAndLevel = () => {
|
||||||
const profilePic = 'https://wallpaperaccess.com/full/317501.jpg';
|
|
||||||
const username = 'Username';
|
const username = 'Username';
|
||||||
const level = '1';
|
const level = '1';
|
||||||
|
|
||||||
@@ -93,19 +23,13 @@ const ProfilePictureBannerAndLevel = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
style={{ height: imageHeight, width: imageWidth, zIndex: 0, opacity: 0.5 }}
|
style={{ height: imageHeight, width: imageWidth, zIndex: 0, opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
<Box zIndex={1} position={'absolute'} marginY={10} marginX={10}>
|
<HStack zIndex={1} space={3} position={'absolute'} marginY={10} marginX={10}>
|
||||||
<Image
|
<UserAvatar size="lg" />
|
||||||
borderRadius={100}
|
<Box>
|
||||||
source={{ uri: profilePic }}
|
|
||||||
alt="Profile picture"
|
|
||||||
size="lg"
|
|
||||||
style={{ position: 'absolute' }}
|
|
||||||
/>
|
|
||||||
<Box w="100%" paddingY={3} paddingLeft={100}>
|
|
||||||
<Heading>{username}</Heading>
|
<Heading>{username}</Heading>
|
||||||
<Heading>Level : {level}</Heading>
|
<Heading>Level : {level}</Heading>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</HStack>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -116,8 +40,6 @@ const ProfileView = () => {
|
|||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'column' }}>
|
<View style={{ flexDirection: 'column' }}>
|
||||||
<ProfilePictureBannerAndLevel />
|
<ProfilePictureBannerAndLevel />
|
||||||
<UserMedals />
|
|
||||||
<PlayerStats />
|
|
||||||
<Box w="10%" paddingY={10} paddingLeft={5} paddingRight={50} zIndex={1}>
|
<Box w="10%" paddingY={10} paddingLeft={5} paddingRight={50} zIndex={1}>
|
||||||
<TextButton
|
<TextButton
|
||||||
onPress={() => navigation.navigate('Settings', { screen: 'profile' })}
|
onPress={() => navigation.navigate('Settings', { screen: 'profile' })}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CardGridCustom from '../components/CardGridCustom';
|
|||||||
import SongCard from '../components/SongCard';
|
import SongCard from '../components/SongCard';
|
||||||
import { useQueries, useQuery } from '../Queries';
|
import { useQueries, useQuery } from '../Queries';
|
||||||
import { LoadingView } from '../components/Loading';
|
import { LoadingView } from '../components/Loading';
|
||||||
|
import ScoreGraph from '../components/ScoreGraph';
|
||||||
|
|
||||||
type ScoreViewProps = {
|
type ScoreViewProps = {
|
||||||
songId: number;
|
songId: number;
|
||||||
@@ -32,6 +33,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
|
|||||||
const artistQuery = useQuery(() => API.getArtist(songQuery.data!.artistId!), {
|
const artistQuery = useQuery(() => API.getArtist(songQuery.data!.artistId!), {
|
||||||
enabled: songQuery.data !== undefined,
|
enabled: songQuery.data !== undefined,
|
||||||
});
|
});
|
||||||
|
const scoresQuery = useQuery(API.getSongHistory(props.songId), { refetchOnWindowFocus: true });
|
||||||
const recommendations = useQuery(API.getSongSuggestions);
|
const recommendations = useQuery(API.getSongSuggestions);
|
||||||
const artistRecommendations = useQueries(
|
const artistRecommendations = useQueries(
|
||||||
recommendations.data
|
recommendations.data
|
||||||
@@ -54,7 +56,7 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView p={8} contentContainerStyle={{ alignItems: 'center' }}>
|
<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">
|
<Text bold fontSize="lg">
|
||||||
{songQuery.data.name}
|
{songQuery.data.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -137,6 +139,9 @@ const ScoreView = (props: RouteProps<ScoreViewProps>) => {
|
|||||||
</Column>
|
</Column>
|
||||||
</Card>
|
</Card>
|
||||||
</Row>
|
</Row>
|
||||||
|
{scoresQuery.data && (scoresQuery.data?.history?.length ?? 0) > 1 && (
|
||||||
|
<ScoreGraph songHistory={scoresQuery.data} />
|
||||||
|
)}
|
||||||
<CardGridCustom
|
<CardGridCustom
|
||||||
style={{ justifyContent: 'space-evenly' }}
|
style={{ justifyContent: 'space-evenly' }}
|
||||||
content={recommendations.data.map((i) => ({
|
content={recommendations.data.map((i) => ({
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface SearchContextType {
|
|||||||
songData: Song[];
|
songData: Song[];
|
||||||
artistData: Artist[];
|
artistData: Artist[];
|
||||||
genreData: Genre[];
|
genreData: Genre[];
|
||||||
favoriteData: Song[];
|
|
||||||
isLoadingSong: boolean;
|
isLoadingSong: boolean;
|
||||||
isLoadingArtist: boolean;
|
isLoadingArtist: boolean;
|
||||||
isLoadingGenre: boolean;
|
isLoadingGenre: boolean;
|
||||||
@@ -33,7 +32,6 @@ export const SearchContext = React.createContext<SearchContextType>({
|
|||||||
songData: [],
|
songData: [],
|
||||||
artistData: [],
|
artistData: [],
|
||||||
genreData: [],
|
genreData: [],
|
||||||
favoriteData: [],
|
|
||||||
isLoadingSong: false,
|
isLoadingSong: false,
|
||||||
isLoadingArtist: false,
|
isLoadingArtist: false,
|
||||||
isLoadingGenre: false,
|
isLoadingGenre: false,
|
||||||
@@ -62,11 +60,6 @@ const SearchView = (props: RouteProps<SearchViewProps>) => {
|
|||||||
{ enabled: !!stringQuery }
|
{ enabled: !!stringQuery }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isLoading: isLoadingFavorite, data: favoriteData = [] } = useQuery(
|
|
||||||
API.getFavorites(),
|
|
||||||
{ enabled: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateFilter = (newData: Filter) => {
|
const updateFilter = (newData: Filter) => {
|
||||||
// called when the filter is changed
|
// called when the filter is changed
|
||||||
setFilter(newData);
|
setFilter(newData);
|
||||||
@@ -87,7 +80,6 @@ const SearchView = (props: RouteProps<SearchViewProps>) => {
|
|||||||
songData,
|
songData,
|
||||||
artistData,
|
artistData,
|
||||||
genreData,
|
genreData,
|
||||||
favoriteData,
|
|
||||||
isLoadingSong,
|
isLoadingSong,
|
||||||
isLoadingArtist,
|
isLoadingArtist,
|
||||||
isLoadingGenre,
|
isLoadingGenre,
|
||||||
|
|||||||
@@ -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 { useQuery } from '../Queries';
|
||||||
import LoadingComponent, { LoadingView } from '../components/Loading';
|
import { LoadingView } from '../components/Loading';
|
||||||
import React, { useEffect, useState } from 'react';
|
import { Translate } from '../i18n/i18n';
|
||||||
import { Translate, translate } from '../i18n/i18n';
|
|
||||||
import formatDuration from 'format-duration';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import API from '../API';
|
import API from '../API';
|
||||||
import TextButton from '../components/TextButton';
|
import TextButton from '../components/TextButton';
|
||||||
import { RouteProps, useNavigation } from '../Navigation';
|
import { RouteProps, useNavigation } from '../Navigation';
|
||||||
|
import ScoreGraph from '../components/ScoreGraph';
|
||||||
|
|
||||||
interface SongLobbyProps {
|
interface SongLobbyProps {
|
||||||
// The unique identifier to find a song
|
// The unique identifier to find a song
|
||||||
@@ -15,6 +14,7 @@ interface SongLobbyProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
|
const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
|
||||||
|
const rootComponentPadding = 30;
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
// Refetch to update score when coming back from score view
|
// Refetch to update score when coming back from score view
|
||||||
const songQuery = useQuery(API.getSong(props.songId), { refetchOnWindowFocus: true });
|
const songQuery = useQuery(API.getSong(props.songId), { refetchOnWindowFocus: true });
|
||||||
@@ -22,18 +22,13 @@ const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
const scoresQuery = useQuery(API.getSongHistory(props.songId), { 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.isLoading || scoresQuery.isLoading) return <LoadingView />;
|
||||||
if (songQuery.isError || scoresQuery.isError) {
|
if (songQuery.isError || scoresQuery.isError) {
|
||||||
navigation.navigate('Error');
|
navigation.navigate('Error');
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: 30, flexDirection: 'column' }}>
|
<Box style={{ padding: rootComponentPadding, flexDirection: 'column' }}>
|
||||||
<Box style={{ flexDirection: 'row', height: '30%' }}>
|
<Box style={{ flexDirection: 'row', height: '30%' }}>
|
||||||
<Box style={{ flex: 3 }}>
|
<Box style={{ flex: 3 }}>
|
||||||
<Image
|
<Image
|
||||||
@@ -117,42 +112,9 @@ const SongLobbyView = (props: RouteProps<SongLobbyProps>) => {
|
|||||||
<Text>{scoresQuery.data?.history.at(0)?.score ?? 0}</Text>
|
<Text>{scoresQuery.data?.history.at(0)?.score ?? 0}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{/* <Text style={{ paddingBottom: 10 }}>{songQuery.data!.description}</Text> */}
|
{scoresQuery.data && (scoresQuery.data?.history?.length ?? 0) > 0 && (
|
||||||
<Box flexDirection="row">
|
<ScoreGraph songHistory={scoresQuery.data} />
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,19 +2,14 @@ import API from '../../API';
|
|||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { unsetAccessToken } from '../../state/UserSlice';
|
import { unsetAccessToken } from '../../state/UserSlice';
|
||||||
import React from 'react';
|
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 TextButton from '../../components/TextButton';
|
||||||
import { LoadingView } from '../../components/Loading';
|
import { LoadingView } from '../../components/Loading';
|
||||||
import ElementList from '../../components/GtkUI/ElementList';
|
import ElementList from '../../components/GtkUI/ElementList';
|
||||||
import { translate } from '../../i18n/i18n';
|
import { translate } from '../../i18n/i18n';
|
||||||
import { useQuery } from '../../Queries';
|
import { useQuery } from '../../Queries';
|
||||||
|
import UserAvatar from '../../components/UserAvatar';
|
||||||
const getInitials = (name: string) => {
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Too painful to infer the settings-only, typed navigator. Gave up
|
// Too painful to infer the settings-only, typed navigator. Gave up
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -41,9 +36,7 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Center>
|
<Center>
|
||||||
<Avatar size="2xl" source={{ uri: user.data.avatar }}>
|
<UserAvatar size="2xl" />
|
||||||
{getInitials(user.name)}
|
|
||||||
</Avatar>
|
|
||||||
</Center>
|
</Center>
|
||||||
<ElementList
|
<ElementList
|
||||||
style={{
|
style={{
|
||||||
@@ -58,7 +51,39 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
|
|||||||
data: {
|
data: {
|
||||||
text: user.email || translate('NoAssociatedEmail'),
|
text: user.email || translate('NoAssociatedEmail'),
|
||||||
onPress: () => {
|
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(),
|
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',
|
type: 'text',
|
||||||
title: translate('nbGamesPlayed'),
|
title: translate('nbGamesPlayed'),
|
||||||
|
|||||||
@@ -9116,6 +9116,18 @@ expo-font@~11.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fontfaceobserver "^2.1.0"
|
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:
|
expo-json-utils@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.4.0.tgz#47ae83a1cc973101d62371f94790e9ad39491751"
|
resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.4.0.tgz#47ae83a1cc973101d62371f94790e9ad39491751"
|
||||||
@@ -9464,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"
|
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==
|
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:
|
filesize@6.1.0:
|
||||||
version "6.1.0"
|
version "6.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
|
resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00"
|
||||||
@@ -14721,6 +14738,11 @@ path-type@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
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:
|
pbkdf2@^3.0.3:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075"
|
resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075"
|
||||||
@@ -14844,6 +14866,11 @@ pnp-webpack-plugin@^1.5.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ts-pnp "^1.1.6"
|
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:
|
polished@^4.2.2:
|
||||||
version "4.2.2"
|
version "4.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1"
|
resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1"
|
||||||
@@ -15752,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"
|
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06"
|
||||||
integrity sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==
|
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:
|
react-native-codegen@^0.70.6:
|
||||||
version "0.70.6"
|
version "0.70.6"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.70.6.tgz#2ce17d1faad02ad4562345f8ee7cbe6397eda5cb"
|
resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.70.6.tgz#2ce17d1faad02ad4562345f8ee7cbe6397eda5cb"
|
||||||
@@ -15821,10 +15857,10 @@ react-native-super-grid@^4.6.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prop-types "^15.6.0"
|
prop-types "^15.6.0"
|
||||||
|
|
||||||
react-native-svg@13.4.0:
|
react-native-svg@^13.10.0:
|
||||||
version "13.4.0"
|
version "13.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-13.4.0.tgz#82399ba0956c454144618aa581e2d748dd3f010a"
|
resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-13.10.0.tgz#d3c6222ea9cc1e21e2af0fd59dfbeafe7a3d0dc1"
|
||||||
integrity sha512-B3TwK+H0+JuRhYPzF21AgqMt4fjhCwDZ9QUtwNstT5XcslJBXC0FoTkdZo8IEb1Sv4suSqhZwlAY6lwOv3tHag==
|
integrity sha512-D/oYTmUi5nsA/2Nw4WYlF1UUi3vZqhpESpiEhpYCIFB/EMd6vz4A/uq3tIzZFcfa5z2oAdGSxRU1TaYr8IcPlQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
css-select "^5.1.0"
|
css-select "^5.1.0"
|
||||||
css-tree "^1.1.3"
|
css-tree "^1.1.3"
|
||||||
|
|||||||