Compare commits
69 Commits
feature/ad
...
front/play
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48724fd554 | ||
| bc13c10f1a | |||
| 91c9e2b295 | |||
| 585be2aa19 | |||
| 654022b48a | |||
| afab03baf8 | |||
| a52c10fc2c | |||
| f2ed598865 | |||
| 02fc8175f4 | |||
|
|
628e50a48d | ||
|
|
70ab56ce3a | ||
|
|
1fefe7912d | ||
|
|
46ef0a7f1b | ||
|
|
64640eda55 | ||
|
|
a6d9cb3b40 | ||
|
|
b61541f7b8 | ||
|
|
3ff523560b | ||
|
|
b61968706d | ||
|
|
2f27278d3a | ||
|
|
e1ab9fe118 | ||
|
|
b1d0415ba0 | ||
|
|
8ab85ab689 | ||
|
|
16cd794e3b | ||
|
|
f85c30a53b | ||
|
|
6da96ed886 | ||
|
|
852fbd5c87 | ||
|
|
7e866f9826 | ||
|
|
2f50f694f3 | ||
|
|
e0f2674811 | ||
|
|
b84ee11f45 | ||
|
|
a2494ce498 | ||
|
|
b76d496034 | ||
|
|
a81d3ee34d | ||
|
|
85473ae492 | ||
|
|
9655e986ff | ||
|
|
101ea8498b | ||
|
|
7d33f85cbc | ||
|
|
66d792715e | ||
|
|
40581f4a45 | ||
|
|
2ca3fcb81a | ||
|
|
30fcacbec6 | ||
|
|
7c3289ccec | ||
|
|
7438986bcd | ||
|
|
3ac017a5f0 | ||
|
|
8e5cc1bc44 | ||
|
|
125a7faf02 | ||
|
|
c9d3ef88e7 | ||
|
|
0ba3bec5aa | ||
|
|
539c35c903 | ||
|
|
e1463d41b9 | ||
|
|
a3676fabf8 | ||
|
|
9f542fc9dd | ||
| 930191569f | |||
| 74cd9c0df2 | |||
| d2642b4fb8 | |||
| ebcc48cc57 | |||
| 95b08935cc | |||
| 04487c9b24 | |||
|
|
20eb62d19b | ||
|
|
567d3250e2 | ||
| 4207d5ee50 | |||
|
|
509cc5b9f8 | ||
|
|
1b22dba9cd | ||
|
|
c0d9ee7ca6 | ||
|
|
27f7945289 | ||
|
|
3d76834f45 | ||
| ccc86895e2 | |||
| 279d16d59a | |||
| 04d288b844 |
@@ -7,4 +7,9 @@ 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
|
||||
SMTP_TRANSPORT=smtps://toto:tata@relay
|
||||
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
|
||||
IGNORE_MAILS=true
|
||||
|
||||
5
.envrc
@@ -1,4 +1 @@
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
|
||||
fi
|
||||
use flake
|
||||
use nix
|
||||
|
||||
16
.github/workflows/CI.yml
vendored
@@ -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,7 +92,8 @@ jobs:
|
||||
- name: Perform healthchecks
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
||||
docker-compose logs
|
||||
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||
|
||||
- name: Run scorometer tests
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
@@ -13,3 +13,4 @@ log.html
|
||||
node_modules/
|
||||
./front/coverage
|
||||
.venv
|
||||
.DS_Store
|
||||
|
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 |
@@ -1,4 +1,4 @@
|
||||
#!/bin/env python3
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
15444
back/package-lock.json
generated
@@ -21,51 +21,58 @@
|
||||
"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-modules/mailer": "^1.9.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",
|
||||
"nodemailer": "^6.9.5",
|
||||
"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/nodemailer": "^6.4.9",
|
||||
"@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": [
|
||||
|
||||
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");
|
||||
2
back/prisma/migrations/20230907141258_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -12,8 +12,10 @@ datasource db {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
password String?
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
partyPlayed Int @default(0)
|
||||
LessonHistory LessonHistory[]
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ArtistModule } from './artist/artist.module';
|
||||
import { AlbumModule } from './album/album.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { HistoryModule } from './history/history.module';
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +29,12 @@ import { HistoryModule } from './history/history.module';
|
||||
SearchModule,
|
||||
SettingsModule,
|
||||
HistoryModule,
|
||||
MailerModule.forRoot({
|
||||
transport: process.env.SMTP_TRANSPORT,
|
||||
defaults: {
|
||||
from: process.env.MAIL_AUTHOR,
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, PrismaService, ArtistService],
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
InternalServerErrorException,
|
||||
Patch,
|
||||
NotFoundException,
|
||||
Req,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpStatus,
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
@@ -32,6 +39,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,17 +52,49 @@ 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) {
|
||||
await this.authService.sendVerifyMail(user);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('verify')
|
||||
async verify(@Request() req: any, @Query('token') token: string): Promise<void> {
|
||||
if (await this.authService.verifyMail(req.user.id, token))
|
||||
return;
|
||||
throw new BadRequestException("Invalid token. Expired or invalid.");
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('reverify')
|
||||
async reverify(@Request() req: any): Promise<void> {
|
||||
await this.authService.sendVerifyMail(req.user);
|
||||
}
|
||||
|
||||
@ApiBody({ type: LoginDto })
|
||||
@HttpCode(200)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@@ -69,6 +111,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 +192,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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import PayloadInterface from './interface/payload.interface';
|
||||
import { User } from 'src/models/user';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private emailService: MailerService,
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
@@ -15,7 +18,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,
|
||||
@@ -31,4 +34,34 @@ export class AuthService {
|
||||
access_token,
|
||||
};
|
||||
}
|
||||
|
||||
async sendVerifyMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === "true") return;
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: '10h' },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: 'chromacase@octohub.app',
|
||||
subject: 'Mail verification for Chromacase',
|
||||
html: `To verify your mail, please click on this <a href="{${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyMail(userId: number, token: string): Promise<boolean> {
|
||||
try {
|
||||
await this.jwtService.verifyAsync(token);
|
||||
} catch(e) {
|
||||
console.log("Verify mail token failure", e);
|
||||
return false;
|
||||
}
|
||||
await this.userService.updateUser({
|
||||
where: { id: userId },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,4 @@ export class SongController {
|
||||
songId: id,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/artist/:artistId')
|
||||
async getSongByArtist(@Param('artistId', ParseIntPipe) artistId: number) {
|
||||
const res = await this.songService.songByArtist(artistId)
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} 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 {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
|
||||
@@ -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,23 @@ 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
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
volumes:
|
||||
- ./back:/app
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
scorometer:
|
||||
image: ghcr.io/chroma-case/scorometer:main
|
||||
ports:
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
scorometer:
|
||||
build: ./scorometer
|
||||
ports:
|
||||
|
||||
43
flake.lock
generated
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1665573177,
|
||||
"narHash": "sha256-Arkrf3zmi3lXYpbSe9H+HQxswQ6jxsAmeQVq5Sr/OZc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d2afb051ffd904af5a825f58abee3c63b148c5f2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
31
flake.nix
@@ -1,31 +0,0 @@
|
||||
{
|
||||
description = "A prisma test project";
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
nativeBuildInputs = [ pkgs.bashInteractive ];
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prisma
|
||||
nodePackages."@nestjs/cli"
|
||||
nodePackages.npm
|
||||
nodejs-slim
|
||||
yarn
|
||||
python3
|
||||
pkg-config
|
||||
];
|
||||
shellHook = with pkgs; ''
|
||||
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
|
||||
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
|
||||
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
|
||||
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
|
||||
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
|
||||
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
82
front/API.ts
@@ -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 './utils/base64ToBlob';
|
||||
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 }
|
||||
)
|
||||
@@ -297,6 +306,24 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all songs corresponding to the given genre ID
|
||||
* @param genreId the id of the genre we're aiming
|
||||
* @returns a promise of an array of Songs
|
||||
*/
|
||||
public static getSongsByGenre(genreId: number): Query<Song[]> {
|
||||
return {
|
||||
key: ['genre', genreId, 'songs'],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/song?genreId=${genreId}`,
|
||||
},
|
||||
{ handler: PlageHandler(SongHandler) }
|
||||
).then(({ data }) => data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrive a song's midi partition
|
||||
* @param songId the id to find the song
|
||||
@@ -332,15 +359,22 @@ 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}`,
|
||||
// }),
|
||||
// }
|
||||
// }
|
||||
/**
|
||||
* Retrieves a genre
|
||||
* @param genreId the id of the aimed genre
|
||||
*/
|
||||
public static getGenre(genreId: number): Query<Genre> {
|
||||
return {
|
||||
key: ['genre', genreId],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/genre/${genreId}`,
|
||||
},
|
||||
{ handler: GenreHandler }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrive a song's musicXML partition
|
||||
@@ -508,16 +542,6 @@ 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
|
||||
@@ -626,4 +650,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { unsetAccessToken } from './state/UserSlice';
|
||||
import TextButton from './components/TextButton';
|
||||
import ErrorView from './views/ErrorView';
|
||||
import GenreDetailsView from './views/GenreDetailsView';
|
||||
import GoogleView from './views/GoogleView';
|
||||
import VerifiedView from './views/VerifiedView';
|
||||
|
||||
// Util function to hide route props in URL
|
||||
const removeMe = () => '';
|
||||
@@ -61,7 +63,7 @@ const protectedRoutes = () =>
|
||||
},
|
||||
Genre: {
|
||||
component: GenreDetailsView,
|
||||
options: { title: translate('genreFilter')},
|
||||
options: { title: translate('genreFilter') },
|
||||
link: '/genre/:genreId',
|
||||
},
|
||||
Score: {
|
||||
@@ -80,6 +82,11 @@ const protectedRoutes = () =>
|
||||
link: undefined,
|
||||
},
|
||||
User: { component: ProfileView, options: { title: translate('user') }, link: '/user' },
|
||||
Verified: {
|
||||
component: VerifiedView,
|
||||
options: { title: 'Verify email', headerShown: false },
|
||||
link: '/verify',
|
||||
},
|
||||
} as const);
|
||||
|
||||
const publicRoutes = () =>
|
||||
@@ -106,6 +113,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
|
||||
|
||||
@@ -4,15 +4,9 @@ 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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
60
front/components/PartitionCoord.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import PartitionView from './PartitionView';
|
||||
import PhaserCanvas from './PartitionVisualizer/PhaserCanvas';
|
||||
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
|
||||
|
||||
type PartitionCoordProps = {
|
||||
// The Buffer of the MusicXML file retreived from the API
|
||||
file: string;
|
||||
onPartitionReady: () => void;
|
||||
onEndReached: () => void;
|
||||
onResume: () => void;
|
||||
onPause: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PartitionCoord = ({
|
||||
file,
|
||||
onPartitionReady,
|
||||
onEndReached,
|
||||
onPause,
|
||||
onResume,
|
||||
timestamp,
|
||||
}: PartitionCoordProps) => {
|
||||
const [partitionData, setPartitionData] = React.useState<
|
||||
[string, PianoCursorPosition[]] | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!partitionData && (
|
||||
<PartitionView
|
||||
file={file}
|
||||
onPartitionReady={(base64data, a) => {
|
||||
setPartitionData([base64data, a]);
|
||||
onPartitionReady();
|
||||
}}
|
||||
onEndReached={() => {
|
||||
console.log('osmd end reached');
|
||||
}}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
{partitionData && (
|
||||
<PhaserCanvas
|
||||
partitionB64={partitionData?.[0]}
|
||||
cursorPositions={partitionData?.[1]}
|
||||
timestamp={timestamp}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onEndReached={() => {
|
||||
onEndReached();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartitionCoord;
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-mixed-spaces-and-tabs */
|
||||
// Inspired from OSMD example project
|
||||
// https://github.com/opensheetmusicdisplay/react-opensheetmusicdisplay/blob/master/src/lib/OpenSheetMusicDisplay.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
CursorType,
|
||||
Fraction,
|
||||
@@ -10,29 +10,23 @@ import {
|
||||
Note,
|
||||
} from 'opensheetmusicdisplay';
|
||||
import useColorScheme from '../hooks/colorScheme';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import SoundFont from 'soundfont-player';
|
||||
import * as SAC from 'standardized-audio-context';
|
||||
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
|
||||
|
||||
type PartitionViewProps = {
|
||||
// The Buffer of the MusicXML file retreived from the API
|
||||
file: string;
|
||||
onPartitionReady: () => void;
|
||||
onPartitionReady: (base64data: string, cursorInfos: PianoCursorPosition[]) => void;
|
||||
onEndReached: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PartitionView = (props: PartitionViewProps) => {
|
||||
const [osmd, setOsmd] = useState<OSMD>();
|
||||
const [soundPlayer, setSoundPlayer] = useState<SoundFont.Player>();
|
||||
const audioContext = new SAC.AudioContext();
|
||||
const [wholeNoteLength, setWholeNoteLength] = useState(0); // Length of Whole note, in ms (?)
|
||||
const colorScheme = useColorScheme();
|
||||
const dimensions = useWindowDimensions();
|
||||
const OSMD_DIV_ID = 'osmd-div';
|
||||
const options: IOSMDOptions = {
|
||||
darkMode: colorScheme == 'dark',
|
||||
backend: 'canvas',
|
||||
drawComposer: false,
|
||||
drawCredits: false,
|
||||
drawLyrics: false,
|
||||
@@ -43,15 +37,15 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
autoResize: false,
|
||||
};
|
||||
// Turns note.Length or timestamp in ms
|
||||
const timestampToMs = (timestamp: Fraction) => {
|
||||
const timestampToMs = (timestamp: Fraction, wholeNoteLength: number) => {
|
||||
return timestamp.RealValue * wholeNoteLength;
|
||||
};
|
||||
const getActualNoteLength = (note: Note) => {
|
||||
let duration = timestampToMs(note.Length);
|
||||
const getActualNoteLength = (note: Note, wholeNoteLength: number) => {
|
||||
let duration = timestampToMs(note.Length, wholeNoteLength);
|
||||
if (note.NoteTie) {
|
||||
const firstNote = note.NoteTie.Notes.at(1);
|
||||
if (Object.is(note.NoteTie.StartNote, note) && firstNote) {
|
||||
duration += timestampToMs(firstNote.Length);
|
||||
duration += timestampToMs(firstNote.Length, wholeNoteLength);
|
||||
} else {
|
||||
duration = 0;
|
||||
}
|
||||
@@ -59,99 +53,79 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
return duration;
|
||||
};
|
||||
|
||||
const playNotesUnderCursor = () => {
|
||||
osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.filter((note) => note.isRest() == false)
|
||||
.filter((note) => note.Pitch) // Pitch Can be null, avoiding them
|
||||
.forEach((note) => {
|
||||
// Put your hands together for https://github.com/jimutt/osmd-audio-player/blob/master/src/internals/noteHelpers.ts
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
// console.log('Expecting midi ' + midiNumber);
|
||||
const duration = getActualNoteLength(note);
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
soundPlayer!.play(midiNumber.toString(), audioContext.currentTime, {
|
||||
duration,
|
||||
gain,
|
||||
});
|
||||
});
|
||||
};
|
||||
const getShortedNoteUnderCursor = () => {
|
||||
return osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
|
||||
.at(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const _osmd = new OSMD(OSMD_DIV_ID, options);
|
||||
Promise.all([
|
||||
SoundFont.instrument(audioContext as unknown as AudioContext, 'electric_piano_1'),
|
||||
_osmd.load(props.file),
|
||||
]).then(([player]) => {
|
||||
setSoundPlayer(player);
|
||||
Promise.all([_osmd.load(props.file)]).then(() => {
|
||||
_osmd.render();
|
||||
_osmd.cursor.hide();
|
||||
// Ty https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL77C12-L77C63
|
||||
_osmd.cursor.show();
|
||||
const bpm = _osmd.Sheet.HasBPMInfo ? _osmd.Sheet.getExpressionsStartTempoInBPM() : 60;
|
||||
setWholeNoteLength(Math.round((60 / bpm) * 4000));
|
||||
props.onPartitionReady();
|
||||
const wholeNoteLength = Math.round((60 / bpm) * 4000);
|
||||
const curPos = [];
|
||||
while (!_osmd.cursor.iterator.EndReached) {
|
||||
const notesToPlay = _osmd.cursor
|
||||
.NotesUnderCursor()
|
||||
.filter((note) => {
|
||||
return note.isRest() == false && note.Pitch;
|
||||
})
|
||||
.map((note) => {
|
||||
return {
|
||||
note: note,
|
||||
duration: getActualNoteLength(note, wholeNoteLength),
|
||||
};
|
||||
});
|
||||
const shortestNotes = _osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
|
||||
.at(0);
|
||||
const ts = timestampToMs(
|
||||
shortestNotes?.getAbsoluteTimestamp() ?? new Fraction(-1),
|
||||
wholeNoteLength
|
||||
);
|
||||
const sNL = timestampToMs(
|
||||
shortestNotes?.Length ?? new Fraction(-1),
|
||||
wholeNoteLength
|
||||
);
|
||||
curPos.push({
|
||||
offset: _osmd.cursor.cursorElement.offsetLeft,
|
||||
notes: notesToPlay,
|
||||
shortedNotes: shortestNotes,
|
||||
sNinfos: {
|
||||
ts,
|
||||
sNL,
|
||||
isRest: shortestNotes?.isRest(),
|
||||
},
|
||||
});
|
||||
_osmd.cursor.next();
|
||||
}
|
||||
// console.log('curPos', curPos);
|
||||
_osmd.cursor.reset();
|
||||
_osmd.cursor.hide();
|
||||
// console.log('timestamp cursor', _osmd.cursor.iterator.CurrentSourceTimestamp);
|
||||
// console.log('timestamp cursor', _osmd.cursor.iterator.CurrentVoiceEntries);
|
||||
// console.log('current measure index', _osmd.cursor.iterator.CurrentMeasureIndex);
|
||||
const osmdCanvas = document.querySelector<HTMLCanvasElement>(
|
||||
'#' + OSMD_DIV_ID + ' canvas'
|
||||
);
|
||||
// this should never happen this is done to silent ts linter about maybe null
|
||||
if (!osmdCanvas) {
|
||||
throw new Error('No canvas found');
|
||||
}
|
||||
// Ty https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL77C12-L77C63
|
||||
props.onPartitionReady(
|
||||
osmdCanvas.toDataURL(),
|
||||
curPos.map((pos) => {
|
||||
return {
|
||||
x: pos.offset,
|
||||
timing: pos.sNinfos.sNL,
|
||||
timestamp: pos.sNinfos.ts,
|
||||
notes: pos.notes,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Do not show cursor before actuall start
|
||||
});
|
||||
setOsmd(_osmd);
|
||||
}, []);
|
||||
|
||||
// Re-render manually (otherwise done by 'autoResize' option), to fix disappearing cursor
|
||||
useEffect(() => {
|
||||
if (osmd && osmd.IsReadyToRender()) {
|
||||
osmd.render();
|
||||
if (!osmd.cursor.hidden) {
|
||||
osmd.cursor.show();
|
||||
}
|
||||
}
|
||||
}, [dimensions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!osmd || !soundPlayer) {
|
||||
return;
|
||||
}
|
||||
if (props.timestamp > 0 && osmd.cursor.hidden && !osmd.cursor.iterator.EndReached) {
|
||||
osmd.cursor.show();
|
||||
playNotesUnderCursor();
|
||||
return;
|
||||
}
|
||||
let previousCursorPosition = -1;
|
||||
let currentCursorPosition = osmd.cursor.cursorElement.offsetLeft;
|
||||
let shortestNote = getShortedNoteUnderCursor();
|
||||
while (
|
||||
!osmd.cursor.iterator.EndReached &&
|
||||
(shortestNote?.isRest
|
||||
? timestampToMs(shortestNote?.getAbsoluteTimestamp() ?? new Fraction(-1)) +
|
||||
timestampToMs(shortestNote?.Length ?? new Fraction(-1)) <
|
||||
props.timestamp
|
||||
: timestampToMs(shortestNote?.getAbsoluteTimestamp() ?? new Fraction(-1)) <
|
||||
props.timestamp)
|
||||
) {
|
||||
previousCursorPosition = currentCursorPosition;
|
||||
osmd.cursor.next();
|
||||
if (osmd.cursor.iterator.EndReached) {
|
||||
osmd.cursor.hide(); // Lousy fix for https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/issues/1338
|
||||
soundPlayer.stop();
|
||||
props.onEndReached();
|
||||
} else {
|
||||
// Shamelessly stolen from https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL223C7-L224C1
|
||||
playNotesUnderCursor();
|
||||
currentCursorPosition = osmd.cursor.cursorElement.offsetLeft;
|
||||
document
|
||||
.getElementById(OSMD_DIV_ID)
|
||||
?.scrollBy(currentCursorPosition - previousCursorPosition, 0);
|
||||
shortestNote = getShortedNoteUnderCursor();
|
||||
}
|
||||
}
|
||||
}, [props.timestamp]);
|
||||
|
||||
return <div id={OSMD_DIV_ID} style={{ width: '100%', overflow: 'hidden' }} />;
|
||||
};
|
||||
|
||||
|
||||
192
front/components/PartitionVisualizer/PhaserCanvas.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
// create a simple phaser effect with a canvas that can be easily imported as a react component
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import Phaser from 'phaser';
|
||||
import useColorScheme from '../../hooks/colorScheme';
|
||||
import { RootState, useSelector } from '../../state/Store';
|
||||
import { setSoundPlayer as setSPStore } from '../../state/SoundPlayerSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { SplendidGrandPiano, CacheStorage } from 'smplr';
|
||||
import { Note } from 'opensheetmusicdisplay';
|
||||
|
||||
let globalTimestamp = 0;
|
||||
const globalStatus: 'playing' | 'paused' | 'stopped' = 'playing';
|
||||
|
||||
const isValidSoundPlayer = (soundPlayer: SplendidGrandPiano | undefined) => {
|
||||
return soundPlayer && soundPlayer.loaded;
|
||||
};
|
||||
|
||||
const myFindLast = <T,>(a: T[], p: (_: T, _2: number) => boolean) => {
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
if (p(a[i]!, i)) {
|
||||
return a[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const playNotes = (notes: PianoCursorNote[], soundPlayer: SplendidGrandPiano) => {
|
||||
notes.forEach(({ note, duration }) => {
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
soundPlayer.start({ note: midiNumber, duration, velocity: gain * 127 });
|
||||
});
|
||||
};
|
||||
|
||||
const getPianoScene = (
|
||||
partitionB64: string,
|
||||
cursorPositions: PianoCursorPosition[],
|
||||
onEndReached: () => void,
|
||||
soundPlayer: SplendidGrandPiano,
|
||||
colorScheme: 'light' | 'dark'
|
||||
) => {
|
||||
class PianoScene extends Phaser.Scene {
|
||||
async preload() {}
|
||||
private cursorPositionsIdx = -1;
|
||||
private partition!: Phaser.GameObjects.Image;
|
||||
private cursor!: Phaser.GameObjects.Rectangle;
|
||||
create() {
|
||||
this.textures.addBase64('partition', partitionB64);
|
||||
this.cursorPositionsIdx = -1;
|
||||
|
||||
this.cameras.main.setBackgroundColor(colorScheme === 'light' ? '#FFFFFF' : '#000000');
|
||||
this.textures.on('onload', () => {
|
||||
this.partition = this.add.image(0, 0, 'partition').setOrigin(0, 0);
|
||||
this.cameras.main.setBounds(0, 0, this.partition.width, this.partition.height);
|
||||
|
||||
this.cursor = this.add.rectangle(0, 0, 30, 350, 0x31ef8c, 0.5).setOrigin(0, 0);
|
||||
this.cameras.main.startFollow(this.cursor, true, 0.05, 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
override update() {
|
||||
const currentTimestamp = globalTimestamp;
|
||||
const status = globalStatus;
|
||||
|
||||
if (status === 'playing') {
|
||||
const transitionTime = 75;
|
||||
const cP = myFindLast(cursorPositions, (cP: { timestamp: number }, idx: number) => {
|
||||
if (
|
||||
cP.timestamp < currentTimestamp + transitionTime &&
|
||||
idx > this.cursorPositionsIdx
|
||||
) {
|
||||
this.cursorPositionsIdx = idx;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (cP) {
|
||||
playNotes(cP.notes, soundPlayer);
|
||||
const tw = {
|
||||
targets: this!.cursor,
|
||||
x: cP!.x,
|
||||
duration: transitionTime,
|
||||
ease: 'Sine.easeInOut',
|
||||
onComplete: undefined as (() => void) | undefined,
|
||||
};
|
||||
if (this.cursorPositionsIdx === cursorPositions.length - 1) {
|
||||
tw.onComplete = () => {
|
||||
onEndReached();
|
||||
};
|
||||
}
|
||||
this.tweens.add(tw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PianoScene;
|
||||
};
|
||||
|
||||
type PianoCursorNote = {
|
||||
note: Note;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type PianoCursorPosition = {
|
||||
// offset in pixels
|
||||
x: number;
|
||||
// timestamp in ms
|
||||
timing: number;
|
||||
timestamp: number;
|
||||
notes: PianoCursorNote[];
|
||||
};
|
||||
|
||||
export type UpdateInfo = {
|
||||
currentTimestamp: number;
|
||||
status: 'playing' | 'paused' | 'stopped';
|
||||
};
|
||||
|
||||
export type PhaserCanvasProps = {
|
||||
partitionB64: string;
|
||||
cursorPositions: PianoCursorPosition[];
|
||||
onEndReached: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PhaserCanvas = ({
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
timestamp,
|
||||
}: PhaserCanvasProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const dispatch = useDispatch();
|
||||
const soundPlayer = useSelector((state: RootState) => state.soundPlayer.soundPlayer);
|
||||
const [game, setGame] = React.useState<Phaser.Game | null>(null);
|
||||
|
||||
globalTimestamp = timestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidSoundPlayer(soundPlayer)) {
|
||||
return;
|
||||
}
|
||||
new SplendidGrandPiano(new AudioContext(), {
|
||||
storage: new CacheStorage(),
|
||||
})
|
||||
.loaded()
|
||||
.then((sp) => {
|
||||
dispatch(setSPStore(sp));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidSoundPlayer(soundPlayer) || !soundPlayer) return;
|
||||
const pianoScene = getPianoScene(
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
soundPlayer,
|
||||
colorScheme
|
||||
);
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'phaser-canvas',
|
||||
width: 1000,
|
||||
height: 400,
|
||||
scene: pianoScene,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_HORIZONTALLY,
|
||||
},
|
||||
};
|
||||
|
||||
setGame(new Phaser.Game(config));
|
||||
return () => {
|
||||
if (game) {
|
||||
// currently the condition is always false
|
||||
game.destroy(true);
|
||||
}
|
||||
};
|
||||
}, [soundPlayer]);
|
||||
|
||||
return <div id="phaser-canvas"></div>;
|
||||
};
|
||||
|
||||
export default PhaserCanvas;
|
||||
@@ -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%">
|
||||
|
||||
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;
|
||||
@@ -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' | 'favorite';
|
||||
export type Filter = 'artist' | 'song' | 'genre' | 'all';
|
||||
|
||||
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,11 +42,6 @@ const SearchBar = () => {
|
||||
callback: () => updateFilter('all'),
|
||||
id: 'all',
|
||||
},
|
||||
{
|
||||
name: translate('favoriteFilter'),
|
||||
callback: () => updateFilter('favorite'),
|
||||
id: 'favorite',
|
||||
},
|
||||
{
|
||||
name: translate('artistFilter'),
|
||||
callback: () => updateFilter('artist'),
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Pressable,
|
||||
Box,
|
||||
Card,
|
||||
Image,
|
||||
Flex,
|
||||
useBreakpointValue,
|
||||
Column,
|
||||
ScrollView,
|
||||
} from 'native-base';
|
||||
import { SafeAreaView, useColorScheme } from 'react-native';
|
||||
import { RootState, useSelector } from '../state/Store';
|
||||
import { SafeAreaView } from 'react-native';
|
||||
import { SearchContext } from '../views/SearchView';
|
||||
import { useQueries, useQuery } from '../Queries';
|
||||
import { translate } from '../i18n/i18n';
|
||||
@@ -24,14 +20,12 @@ import ArtistCard from './ArtistCard';
|
||||
import GenreCard from './GenreCard';
|
||||
import SongCard from './SongCard';
|
||||
import CardGridCustom from './CardGridCustom';
|
||||
import TextButton from './TextButton';
|
||||
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,
|
||||
name: song.name,
|
||||
@@ -39,101 +33,6 @@ const swaToSongCardProps = (song: SongWithArtist) => ({
|
||||
cover: song.cover ?? 'https://picsum.photos/200',
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// };
|
||||
|
||||
SongRow.defaultProps = {
|
||||
onPress: () => {},
|
||||
};
|
||||
|
||||
const HomeSearchComponent = () => {
|
||||
const { updateStringQuery } = React.useContext(SearchContext);
|
||||
const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery(
|
||||
@@ -254,15 +153,17 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
|
||||
</Text>
|
||||
{artistData?.length ? (
|
||||
<CardGridCustom
|
||||
content={artistData.slice(0, props.maxItems ?? artistData.length).map((artistData) => ({
|
||||
image: API.getArtistIllustration(artistData.id),
|
||||
name: artistData.name,
|
||||
id: artistData.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(artistData.name, 'artist');
|
||||
navigation.navigate('Artist', { artistId: artistData.id });
|
||||
},
|
||||
}))}
|
||||
content={artistData
|
||||
.slice(0, props.maxItems ?? artistData.length)
|
||||
.map((artistData) => ({
|
||||
image: API.getArtistIllustration(artistData.id),
|
||||
name: artistData.name,
|
||||
id: artistData.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(artistData.name, 'artist');
|
||||
navigation.navigate('Artist', { artistId: artistData.id });
|
||||
},
|
||||
}))}
|
||||
cardComponent={ArtistCard}
|
||||
/>
|
||||
) : (
|
||||
@@ -289,7 +190,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
|
||||
id: g.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(g.name, 'genre');
|
||||
navigation.navigate('Genre', {genreId: g.id});
|
||||
navigation.navigate('Genre', { genreId: g.id });
|
||||
},
|
||||
}))}
|
||||
cardComponent={GenreCard}
|
||||
@@ -301,35 +202,6 @@ 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';
|
||||
@@ -375,8 +247,6 @@ const FilterSwitch = () => {
|
||||
return <ArtistSearchComponent />;
|
||||
case 'genre':
|
||||
return <GenreSearchComponent />;
|
||||
case 'favorite':
|
||||
return <FavoriteSearchComponent />;
|
||||
default:
|
||||
return <Text>Something very bad happened: {currentFilter}</Text>;
|
||||
}
|
||||
@@ -384,8 +254,7 @@ const FilterSwitch = () => {
|
||||
|
||||
export const SearchResultComponent = () => {
|
||||
const { stringQuery } = React.useContext(SearchContext);
|
||||
const { filter } = React.useContext(SearchContext);
|
||||
const shouldOutput = !!stringQuery.trim() || filter == "favorite";
|
||||
const shouldOutput = !!stringQuery.trim();
|
||||
|
||||
return shouldOutput ? (
|
||||
<Box p={5}>
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
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";
|
||||
import { HStack, Image, Text } from 'native-base';
|
||||
import Song, { SongWithArtist } from '../models/Song';
|
||||
import RowCustom from './RowCustom';
|
||||
import TextButton from './TextButton';
|
||||
|
||||
type SongRowProps = {
|
||||
liked: boolean;
|
||||
song: Song | SongWithArtist; // TODO: remove Song
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const handleLikeButton = {
|
||||
}
|
||||
|
||||
const SongRow = ({ song, onPress, liked }: SongRowProps) => {
|
||||
const SongRow = ({ song, onPress }: SongRowProps) => {
|
||||
return (
|
||||
<RowCustom width={'100%'}>
|
||||
<HStack px={2} space={5} justifyContent={'space-between'}>
|
||||
@@ -28,10 +22,6 @@ const SongRow = ({ song, onPress, liked }: SongRowProps) => {
|
||||
borderColor={'white'}
|
||||
borderWidth={1}
|
||||
/>
|
||||
<IconButton size={'sm'} variant="ghost" _icon={{
|
||||
as: MaterialIcons,
|
||||
name: !liked ? "favorite-outline" : 'favorite',
|
||||
}} />
|
||||
<HStack
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
37
front/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
// NOTE: We do this to avoid parsing URL with `new URL`, which is not compatible with related path
|
||||
// (which is used for production, on web)
|
||||
return `${user.data.data.avatar}?updatedAt=${user.dataUpdatedAt.toString()}`;
|
||||
}, [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',
|
||||
signInBtn: 'Sign in',
|
||||
signUpBtn: 'Sign up',
|
||||
continuewithgoogle: 'Continue with Google',
|
||||
changeLanguageBtn: 'Change language',
|
||||
search: 'Search',
|
||||
login: 'Login',
|
||||
@@ -42,7 +43,7 @@ export const en = {
|
||||
artistFilter: 'Artists',
|
||||
songsFilter: 'Songs',
|
||||
genreFilter: 'Genres',
|
||||
favoriteFilter: 'Favorites',
|
||||
favoriteFilter: 'Favorites',
|
||||
|
||||
// profile page
|
||||
user: 'Profile',
|
||||
@@ -180,6 +181,9 @@ export const en = {
|
||||
|
||||
recentSearches: 'Recent searches',
|
||||
noRecentSearches: 'No recent searches',
|
||||
avatar: 'Avatar',
|
||||
changeIt: 'Change It',
|
||||
verified: 'Verified',
|
||||
};
|
||||
|
||||
export const fr: typeof en = {
|
||||
@@ -190,6 +194,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',
|
||||
@@ -228,7 +233,7 @@ export const fr: typeof en = {
|
||||
artistFilter: 'Artistes',
|
||||
songsFilter: 'Morceaux',
|
||||
genreFilter: 'Genres',
|
||||
favoriteFilter: 'Favoris',
|
||||
favoriteFilter: 'Favoris',
|
||||
|
||||
// Difficulty settings
|
||||
diffBtn: 'Difficulté',
|
||||
@@ -362,6 +367,9 @@ export const fr: typeof en = {
|
||||
|
||||
recentSearches: 'Recherches récentes',
|
||||
noRecentSearches: 'Aucune recherche récente',
|
||||
avatar: 'Avatar',
|
||||
changeIt: 'Modifier',
|
||||
verified: 'Verifié',
|
||||
};
|
||||
|
||||
export const sp: typeof en = {
|
||||
@@ -424,7 +432,7 @@ export const sp: typeof en = {
|
||||
artistFilter: 'Artistas',
|
||||
songsFilter: 'canciones',
|
||||
genreFilter: 'géneros',
|
||||
favoriteFilter: 'Favorites',
|
||||
favoriteFilter: 'Favorites',
|
||||
|
||||
// Difficulty settings
|
||||
diffBtn: 'Dificultad',
|
||||
@@ -548,4 +556,9 @@ export const sp: typeof en = {
|
||||
|
||||
recentSearches: 'Búsquedas recientes',
|
||||
noRecentSearches: 'No hay búsquedas recientes',
|
||||
continuewithgoogle: 'Continuar con Google',
|
||||
|
||||
avatar: 'Avatar',
|
||||
changeIt: 'Cambialo',
|
||||
verified: 'Verified',
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import Model, { ModelValidator } from './Model';
|
||||
import * as yup from 'yup';
|
||||
import ResponseHandler from './ResponseHandler';
|
||||
|
||||
export const SearchType = ['song', 'artist', 'album'] as const;
|
||||
export const SearchType = ['song', 'artist', 'album', 'genre'] as const;
|
||||
export type SearchType = (typeof SearchType)[number];
|
||||
|
||||
const SearchHistoryValidator = yup
|
||||
@@ -27,7 +27,7 @@ export const SearchHistoryHandler: ResponseHandler<
|
||||
|
||||
interface SearchHistory extends Model {
|
||||
query: string;
|
||||
type: 'song' | 'artist' | 'album' | 'genre';
|
||||
type: SearchType;
|
||||
userId: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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(),
|
||||
emailVerified: yup.boolean().required(),
|
||||
googleID: yup.string().required().nullable(),
|
||||
isGuest: yup.boolean().required(),
|
||||
partyPlayed: yup.number().required(),
|
||||
})
|
||||
@@ -22,7 +25,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 +33,8 @@ export const UserHandler: ResponseHandler<yup.InferType<typeof UserValidator>, U
|
||||
interface User extends Model {
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
googleID: string | null;
|
||||
isGuest: boolean;
|
||||
premium: boolean;
|
||||
data: UserData;
|
||||
@@ -38,7 +43,7 @@ interface User extends Model {
|
||||
interface UserData {
|
||||
gamesPlayed: number;
|
||||
xp: number;
|
||||
avatar: string | undefined;
|
||||
avatar: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ server {
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"pretty:check": "prettier --check",
|
||||
"pretty:write": "prettier --write",
|
||||
"pretty:check": "prettier --check .",
|
||||
"pretty:write": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"test": "jest -i",
|
||||
"test:cov": "jest -i --coverage",
|
||||
@@ -34,7 +34,7 @@
|
||||
"expo": "^47.0.8",
|
||||
"expo-asset": "~8.7.0",
|
||||
"expo-dev-client": "~2.0.1",
|
||||
"expo-linear-gradient": "^12.3.0",
|
||||
"expo-image-picker": "~14.0.2",
|
||||
"expo-linking": "~3.3.1",
|
||||
"expo-screen-orientation": "~5.0.1",
|
||||
"expo-secure-store": "~12.0.0",
|
||||
@@ -49,16 +49,18 @@
|
||||
"moti": "^0.22.0",
|
||||
"native-base": "^3.4.17",
|
||||
"opensheetmusicdisplay": "^1.7.5",
|
||||
"phaser": "^3.60.0",
|
||||
"react": "18.1.0",
|
||||
"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",
|
||||
@@ -66,8 +68,7 @@
|
||||
"react-timer-hook": "^3.0.5",
|
||||
"react-use-precision-timer": "^3.3.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"soundfont-player": "^0.12.0",
|
||||
"standardized-audio-context": "^25.3.51",
|
||||
"smplr": "^0.6.1",
|
||||
"type-fest": "^3.6.0",
|
||||
"yup": "^1.2.0"
|
||||
},
|
||||
|
||||
19
front/state/SoundPlayerSlice.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { SplendidGrandPiano } from 'smplr';
|
||||
|
||||
export const soundPlayerSlice = createSlice({
|
||||
name: 'soundPlayer',
|
||||
initialState: {
|
||||
soundPlayer: undefined as SplendidGrandPiano | undefined,
|
||||
},
|
||||
reducers: {
|
||||
setSoundPlayer: (state, action: PayloadAction<SplendidGrandPiano>) => {
|
||||
state.soundPlayer = action.payload;
|
||||
},
|
||||
unsetSoundPlayer: (state) => {
|
||||
state.soundPlayer = undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { setSoundPlayer, unsetSoundPlayer } = soundPlayerSlice.actions;
|
||||
export default soundPlayerSlice.reducer;
|
||||
@@ -1,5 +1,6 @@
|
||||
import userReducer from '../state/UserSlice';
|
||||
import settingsReduder from './SettingsSlice';
|
||||
import settingsReducer from './SettingsSlice';
|
||||
import SoundPlayerSliceReducer from './SoundPlayerSlice';
|
||||
import { StateFromReducersMapObject, configureStore } from '@reduxjs/toolkit';
|
||||
import languageReducer from './LanguageSlice';
|
||||
import {
|
||||
@@ -28,7 +29,8 @@ const persistConfig = {
|
||||
const reducers = {
|
||||
user: userReducer,
|
||||
language: languageReducer,
|
||||
settings: settingsReduder,
|
||||
settings: settingsReducer,
|
||||
soundPlayer: SoundPlayerSliceReducer,
|
||||
};
|
||||
|
||||
type State = StateFromReducersMapObject<typeof reducers>;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/* Language and Environment */
|
||||
"target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2019",
|
||||
"es2022",
|
||||
"DOM"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react-native" /* Specify what JSX code is generated. */,
|
||||
|
||||
23
front/utils/base64ToBlob.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// SRC: https://github.com/encrypit/file64/blob/master/src/base64-to-blob.ts
|
||||
export async function base64ToBlob(base64: string): Promise<Blob> {
|
||||
const response = await fetch(base64);
|
||||
let blob = await response.blob();
|
||||
const mimeType = getMimeType(base64);
|
||||
if (mimeType) {
|
||||
// https://stackoverflow.com/a/50875615
|
||||
blob = blob.slice(0, blob.size, mimeType);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
const mimeRegex = /^data:(.+);base64,/;
|
||||
|
||||
/**
|
||||
* Gets MIME type from Base64.
|
||||
*
|
||||
* @param base64 - Base64.
|
||||
* @returns - MIME type.
|
||||
*/
|
||||
function getMimeType(base64: string) {
|
||||
return base64.match(mimeRegex)?.slice(1, 2).pop();
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
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 { Box, Heading, useBreakpointValue, ScrollView } from 'native-base';
|
||||
import { useQuery } from '../Queries';
|
||||
import { LoadingView } from '../components/Loading';
|
||||
import API from '../API';
|
||||
import Song, { SongWithArtist } from '../models/Song';
|
||||
import Song 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;
|
||||
@@ -34,21 +30,19 @@ const ArtistDetailsView = ({ artistId }: RouteProps<ArtistDetailsViewProps>) =>
|
||||
return (
|
||||
<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>
|
||||
style={{ width: '100%', height: isMobileView ? 200 : 300 }}
|
||||
source={{ uri: API.getArtistIllustration(artistQuery.data.id) }}
|
||||
></ImageBackground>
|
||||
<Box>
|
||||
<Heading mt={-20} ml={3} fontSize={50}>{artistQuery.data.name}</Heading>
|
||||
<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 });
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,117 +1,74 @@
|
||||
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 { Flex, Heading, useBreakpointValue, ScrollView } from 'native-base';
|
||||
import { useQueries, useQuery } from '../Queries';
|
||||
import { LoadingView } from '../components/Loading';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { RouteProps, 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';
|
||||
import SongCard from '../components/SongCard';
|
||||
import { ImageBackground } from 'react-native';
|
||||
|
||||
const colorRange = [
|
||||
{
|
||||
code: '#364fc7',
|
||||
},
|
||||
{
|
||||
code: '#5c940d',
|
||||
},
|
||||
{
|
||||
code: '#c92a2a',
|
||||
},
|
||||
{
|
||||
code: '#d6336c',
|
||||
},
|
||||
{
|
||||
code: '#20c997'
|
||||
}
|
||||
]
|
||||
type GenreDetailsViewProps = {
|
||||
genreId: number;
|
||||
};
|
||||
|
||||
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 }: RouteProps<GenreDetailsViewProps>) => {
|
||||
const genreQuery = useQuery(API.getGenre(genreId));
|
||||
const songsQuery = useQuery(API.getSongsByGenre(genreId));
|
||||
const artistQueries = useQueries(
|
||||
songsQuery.data?.map((song) => song.artistId).map((artistId) => API.getArtist(artistId)) ??
|
||||
[]
|
||||
);
|
||||
// Here, .artist will always be defined
|
||||
const songWithArtist = songsQuery?.data
|
||||
?.map((song) => ({
|
||||
...song,
|
||||
artist: artistQueries.find((query) => query.data?.id == song.artistId)?.data,
|
||||
}))
|
||||
.filter((song) => song.artist !== undefined);
|
||||
|
||||
const 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 screenSize = useBreakpointValue({ base: 'small', md: 'big' });
|
||||
const isMobileView = screenSize == 'small';
|
||||
const navigation = useNavigation();
|
||||
|
||||
// if (isLoadingGenre) {
|
||||
// return <LoadingView />;
|
||||
// }
|
||||
|
||||
// if (isErrorGenre) {
|
||||
// navigation.navigate('Error');
|
||||
// }
|
||||
if (genreQuery.isError || songsQuery.isError) {
|
||||
navigation.navigate('Error');
|
||||
return <></>;
|
||||
}
|
||||
if (!genreQuery.data || songsQuery.data === undefined || songWithArtist === undefined) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
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],
|
||||
},}}
|
||||
/>
|
||||
<ImageBackground
|
||||
style={{ width: '100%', height: isMobileView ? 200 : 300 }}
|
||||
source={{ uri: API.getGenreIllustration(genreQuery.data.id) }}
|
||||
></ImageBackground>
|
||||
<Heading ml={3} fontSize={50}>
|
||||
{genreQuery.data.name}
|
||||
</Heading>
|
||||
<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,
|
||||
content={songWithArtist.map((songData) => ({
|
||||
name: songData.name,
|
||||
cover: songData.cover,
|
||||
artistName: songData.artist!.name,
|
||||
songId: songData.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(artistData.name, 'artist');
|
||||
navigation.navigate('Artist', { artistId: artistData.id });
|
||||
API.createSearchHistoryEntry(songData.name, 'song');
|
||||
navigation.navigate('Song', { songId: songData.id });
|
||||
},
|
||||
}))}
|
||||
cardComponent={ArtistCard}
|
||||
cardComponent={SongCard}
|
||||
/>
|
||||
) : (
|
||||
<Text>{translate('errNoResults')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
</Box>
|
||||
</Flex>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default GenreDetailsView;
|
||||
export default GenreDetailsView;
|
||||
|
||||
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,4 +1,5 @@
|
||||
/* eslint-disable no-mixed-spaces-and-tabs */
|
||||
import { StackActions } from '@react-navigation/native';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { SafeAreaView, Platform, Animated } from 'react-native';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
@@ -15,20 +16,18 @@ import {
|
||||
HStack,
|
||||
} from 'native-base';
|
||||
import IconButton from '../components/IconButton';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { RouteProps, useNavigation } from '../Navigation';
|
||||
import { transformQuery, useQuery } from '../Queries';
|
||||
import API from '../API';
|
||||
import LoadingComponent, { LoadingView } from '../components/Loading';
|
||||
import Constants from 'expo-constants';
|
||||
import VirtualPiano from '../components/VirtualPiano/VirtualPiano';
|
||||
import { strToKey, keyToStr, Note } from '../models/Piano';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../state/Store';
|
||||
import { translate } from '../i18n/i18n';
|
||||
import { ColorSchemeType } from 'native-base/lib/typescript/components/types';
|
||||
import { useStopwatch } from 'react-use-precision-timer';
|
||||
import PartitionView from '../components/PartitionView';
|
||||
import PartitionCoord from '../components/PartitionCoord';
|
||||
import TextButton from '../components/TextButton';
|
||||
import { MIDIAccess, MIDIMessageEvent, requestMIDIAccess } from '@motiz88/react-native-midi';
|
||||
import * as Linking from 'expo-linking';
|
||||
@@ -78,8 +77,7 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
const webSocket = useRef<WebSocket>();
|
||||
const [paused, setPause] = useState<boolean>(true);
|
||||
const stopwatch = useStopwatch();
|
||||
const [isVirtualPianoVisible, setVirtualPianoVisible] = useState<boolean>(false);
|
||||
const [time, setTime] = useState(0);
|
||||
const time = useRef(0);
|
||||
const [partitionRendered, setPartitionRendered] = useState(false); // Used to know when partitionview can render
|
||||
const [score, setScore] = useState(0); // Between 0 and 100
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
@@ -117,6 +115,12 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
);
|
||||
};
|
||||
const onEnd = () => {
|
||||
stopwatch.stop();
|
||||
if (webSocket.current?.readyState != WebSocket.OPEN) {
|
||||
console.warn('onEnd: Websocket not open');
|
||||
navigation.dispatch(StackActions.replace('Home'));
|
||||
return;
|
||||
}
|
||||
webSocket.current?.send(
|
||||
JSON.stringify({
|
||||
type: 'end',
|
||||
@@ -126,6 +130,7 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
|
||||
const onMIDISuccess = (access: MIDIAccess) => {
|
||||
const inputs = access.inputs;
|
||||
let endMsgReceived = false; // Used to know if to go to error screen when websocket closes
|
||||
|
||||
if (inputs.size < 2) {
|
||||
toast.show({ description: 'No MIDI Keyboard found' });
|
||||
@@ -144,11 +149,25 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
})
|
||||
);
|
||||
};
|
||||
webSocket.current.onclose = () => {
|
||||
console.log('Websocket closed', endMsgReceived);
|
||||
if (!endMsgReceived) {
|
||||
toast.show({ description: 'Connection lost with Scorometer' });
|
||||
// the special case when the front send the end message succesfully
|
||||
// but the websocket is closed before the end message is received
|
||||
// is not handled
|
||||
return;
|
||||
}
|
||||
};
|
||||
webSocket.current.onmessage = (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'end') {
|
||||
navigation.navigate('Score', { songId: song.data!.id, ...data });
|
||||
endMsgReceived = true;
|
||||
webSocket.current?.close();
|
||||
navigation.dispatch(
|
||||
StackActions.replace('Score', { songId: song.data!.id, ...data })
|
||||
);
|
||||
return;
|
||||
}
|
||||
const points = data.info.score;
|
||||
@@ -217,12 +236,12 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
useEffect(() => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE).catch(() => {});
|
||||
const interval = setInterval(() => {
|
||||
setTime(() => getElapsedTime()); // Countdown
|
||||
time.current = getElapsedTime(); // Countdown
|
||||
}, 1);
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.unlockAsync().catch(() => {});
|
||||
onEnd();
|
||||
stopwatch.stop();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
@@ -268,50 +287,17 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
</Animated.View>
|
||||
</HStack>
|
||||
<View style={{ flexGrow: 1, justifyContent: 'center' }}>
|
||||
<PartitionView
|
||||
<PartitionCoord
|
||||
file={musixml.data}
|
||||
timestamp={time.current}
|
||||
onEndReached={onEnd}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onPartitionReady={() => setPartitionRendered(true)}
|
||||
timestamp={Math.max(0, time)}
|
||||
onEndReached={() => {
|
||||
onEnd();
|
||||
}}
|
||||
/>
|
||||
{!partitionRendered && <LoadingComponent />}
|
||||
</View>
|
||||
|
||||
{isVirtualPianoVisible && (
|
||||
<Column
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
height: '20%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<VirtualPiano
|
||||
onNoteDown={(note) => {
|
||||
console.log('On note down', keyToStr(note));
|
||||
}}
|
||||
onNoteUp={(note) => {
|
||||
console.log('On note up', keyToStr(note));
|
||||
}}
|
||||
showOctaveNumbers={true}
|
||||
startNote={Note.C}
|
||||
endNote={Note.B}
|
||||
startOctave={2}
|
||||
endOctave={5}
|
||||
style={{
|
||||
width: '80%',
|
||||
height: '100%',
|
||||
}}
|
||||
highlightedNotes={[
|
||||
{ key: strToKey('D3') },
|
||||
{ key: strToKey('A#'), bgColor: '#00FF00' },
|
||||
]}
|
||||
/>
|
||||
</Column>
|
||||
)}
|
||||
<Box
|
||||
shadow={4}
|
||||
style={{
|
||||
@@ -355,29 +341,15 @@ const PlayView = ({ songId, type, route }: RouteProps<PlayViewProps>) => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
colorScheme="coolGray"
|
||||
variant="solid"
|
||||
icon={
|
||||
<Icon
|
||||
as={MaterialCommunityIcons}
|
||||
name={isVirtualPianoVisible ? 'piano-off' : 'piano'}
|
||||
/>
|
||||
}
|
||||
onPress={() => {
|
||||
setVirtualPianoVisible(!isVirtualPianoVisible);
|
||||
}}
|
||||
/>
|
||||
<Text>
|
||||
{time < 0
|
||||
{time.current < 0
|
||||
? paused
|
||||
? '0:00'
|
||||
: Math.floor((time % 60000) / 1000)
|
||||
: Math.floor((time.current % 60000) / 1000)
|
||||
.toFixed(0)
|
||||
.toString()
|
||||
: `${Math.floor(time / 60000)}:${Math.floor(
|
||||
(time % 60000) / 1000
|
||||
: `${Math.floor(time.current / 60000)}:${Math.floor(
|
||||
(time.current % 60000) / 1000
|
||||
)
|
||||
.toFixed(0)
|
||||
.toString()
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -12,14 +12,13 @@ import { ScrollView } from 'native-base';
|
||||
import { RouteProps } from '../Navigation';
|
||||
|
||||
interface SearchContextType {
|
||||
filter: 'artist' | 'song' | 'genre' | 'all' | 'favorite';
|
||||
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all' | 'favorite') => void;
|
||||
filter: 'artist' | 'song' | 'genre' | 'all';
|
||||
updateFilter: (newData: 'artist' | 'song' | 'genre' | 'all') => void;
|
||||
stringQuery: string;
|
||||
updateStringQuery: (newData: string) => void;
|
||||
songData: Song[];
|
||||
artistData: Artist[];
|
||||
genreData: Genre[];
|
||||
favoriteData: Song[];
|
||||
isLoadingSong: boolean;
|
||||
isLoadingArtist: boolean;
|
||||
isLoadingGenre: boolean;
|
||||
@@ -33,7 +32,6 @@ export const SearchContext = React.createContext<SearchContextType>({
|
||||
songData: [],
|
||||
artistData: [],
|
||||
genreData: [],
|
||||
favoriteData: [],
|
||||
isLoadingSong: false,
|
||||
isLoadingArtist: false,
|
||||
isLoadingGenre: false,
|
||||
@@ -62,11 +60,6 @@ const SearchView = (props: RouteProps<SearchViewProps>) => {
|
||||
{ enabled: !!stringQuery }
|
||||
);
|
||||
|
||||
const { isLoading: isLoadingFavorite, data: favoriteData = [] } = useQuery(
|
||||
API.getFavorites(),
|
||||
{ enabled: true }
|
||||
)
|
||||
|
||||
const updateFilter = (newData: Filter) => {
|
||||
// called when the filter is changed
|
||||
setFilter(newData);
|
||||
@@ -87,7 +80,6 @@ const SearchView = (props: RouteProps<SearchViewProps>) => {
|
||||
songData,
|
||||
artistData,
|
||||
genreData,
|
||||
favoriteData,
|
||||
isLoadingSong,
|
||||
isLoadingArtist,
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
|
||||
35
front/views/VerifiedView.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import API from '../API';
|
||||
import { Text } from 'native-base';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
|
||||
const VerifiedView = () => {
|
||||
const navigation = useNavigation();
|
||||
const route = useRoute();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
try {
|
||||
await API.fetch({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
route: `/auth/verify?token=${(route.params as any).token}`,
|
||||
method: 'PUT',
|
||||
});
|
||||
navigation.navigate('Home');
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return failed ? (
|
||||
<Text>Email verification failed. The token has expired or is invalid.</Text>
|
||||
) : (
|
||||
<Text>Loading please wait</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifiedView;
|
||||
@@ -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,49 @@ const ProfileSettings = ({ navigation }: { navigation: any }) => {
|
||||
data: {
|
||||
text: user.email || translate('NoAssociatedEmail'),
|
||||
onPress: () => {
|
||||
navigation.navigate('ChangeEmail');
|
||||
navigation.navigate('changeEmail');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
title: translate('verified'),
|
||||
data: {
|
||||
text: user.emailVerified ? 'verified' : 'not verified',
|
||||
onPress: user.emailVerified
|
||||
? undefined
|
||||
: () => API.fetch({ route: '/auth/reverify', method: 'PUT' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
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 +122,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'),
|
||||
|
||||
128
front/yarn.lock
@@ -1239,13 +1239,6 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@babel/runtime@^7.22.3":
|
||||
version "7.22.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.3.tgz#0a7fce51d43adbf0f7b517a71f4c3aaca92ebcbb"
|
||||
integrity sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@babel/runtime@~7.5.4":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
|
||||
@@ -5556,11 +5549,6 @@ address@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e"
|
||||
integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==
|
||||
|
||||
adsr@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/adsr/-/adsr-1.0.1.tgz#a7bc08e5ef8a71e6364abc96fce7df1c44881cc3"
|
||||
integrity sha512-thr9LK4jxApOzBA33IWOA83bXJFbyfbeozpHXyrMQOIhUni198uRxXqDhobW0S/51iokqty2Yz2WbLZbE6tntQ==
|
||||
|
||||
agent-base@6, agent-base@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
|
||||
@@ -6014,19 +6002,6 @@ atob@^2.1.2:
|
||||
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
|
||||
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
|
||||
|
||||
audio-loader@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/audio-loader/-/audio-loader-0.5.0.tgz#9c125d1b25c33cd9626084054d9f6b7f31ddc908"
|
||||
integrity sha512-mEoYRjZhqkBSen/X9i2PNosqvafEsur8bI5MNoPr0wsJu9Nzlul3Yv1elYeMPsXxTxYhXLY8AZlScBvaK4mydg==
|
||||
|
||||
automation-events@^6.0.4:
|
||||
version "6.0.4"
|
||||
resolved "https://registry.yarnpkg.com/automation-events/-/automation-events-6.0.4.tgz#a308501319b9f921de7165e0b1a201b46cd1ab59"
|
||||
integrity sha512-3C/7GtIB1rEwXfSEMUaJRZJFaDJWyiZ3g+Z1HWVAZj+SYZDGKZiZKTZ+Kfq0Lmnb0hL5RXtJ5prfMXbC10evzA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.22.3"
|
||||
tslib "^2.5.3"
|
||||
|
||||
autoprefixer@^9.8.6:
|
||||
version "9.8.8"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.8.tgz#fd4bd4595385fa6f06599de749a4d5f7a474957a"
|
||||
@@ -8921,6 +8896,11 @@ eventemitter3@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
||||
eventemitter3@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
|
||||
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
||||
|
||||
events@^3.0.0, events@^3.2.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
||||
@@ -9116,6 +9096,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"
|
||||
@@ -13397,11 +13389,6 @@ midi-player-js@^2.0.16:
|
||||
resolved "https://registry.yarnpkg.com/midi-player-js/-/midi-player-js-2.0.16.tgz#41167859e3f430e55eeb962887cb498726d6c570"
|
||||
integrity sha512-Y1yCRvvSjJjT5J4U8T4XTCDF1FLXtw8Otvq5BAmIob/2cj10aQUDrPDFByTWeuMRPu6/nLhusROc1DuTLCzRnw==
|
||||
|
||||
midimessage@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/midimessage/-/midimessage-1.0.5.tgz#ad99f04d863a053a2563d553c5bf35070b48802c"
|
||||
integrity sha512-MPJ2tDupFOfZB5/PLp8fri1IS4fd9hPj0Bio//FBhWRQ+TsJA7/49CF1aJyraDxa0Jq8zMHAwrwXl2GINvLvgw==
|
||||
|
||||
miller-rabin@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
|
||||
@@ -14004,16 +13991,6 @@ normalize-url@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
|
||||
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
|
||||
|
||||
note-parser@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/note-parser/-/note-parser-1.1.0.tgz#12e9f17e51450ec994f1364a01982c22667b8e6b"
|
||||
integrity sha512-YTqWQBsRp40EFrEznnkGtmx68gcgOQ8CdoBspqGBA3G1/4mJwIYbDe/vuNpX3oGX2DhP7b1dBgTmj7p3Zr0P1Q==
|
||||
|
||||
note-parser@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/note-parser/-/note-parser-2.0.1.tgz#2438fd57a46894b402b3a2071798660129c8fbc1"
|
||||
integrity sha512-w9o6Fv46y3NsFxeezTZSmftBtUM/ypme6iZWVrTJvvsD5RN+w0XNDePWtfreNrZFL3jSjBFhadPoXb+pJO4UdA==
|
||||
|
||||
npm-package-arg@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-7.0.0.tgz#52cdf08b491c0c59df687c4c925a89102ef794a5"
|
||||
@@ -14721,6 +14698,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"
|
||||
@@ -14732,6 +14714,13 @@ pbkdf2@^3.0.3:
|
||||
safe-buffer "^5.0.1"
|
||||
sha.js "^2.4.8"
|
||||
|
||||
phaser@^3.60.0:
|
||||
version "3.60.0"
|
||||
resolved "https://registry.yarnpkg.com/phaser/-/phaser-3.60.0.tgz#8a555623e64c707482e6321485b4bda84604590d"
|
||||
integrity sha512-IKUy35EnoEVcl2EmJ8WOyK4X8OoxHYdlhZLgRGpNrvD1fEagYffhVmwHcapE/tGiLgyrnezmXIo5RrH2NcrTHw==
|
||||
dependencies:
|
||||
eventemitter3 "^5.0.0"
|
||||
|
||||
picocolors@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f"
|
||||
@@ -14844,6 +14833,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"
|
||||
@@ -15752,6 +15746,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"
|
||||
@@ -15821,10 +15824,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"
|
||||
@@ -16568,15 +16571,6 @@ safe-regex@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
sample-player@^0.5.5:
|
||||
version "0.5.5"
|
||||
resolved "https://registry.yarnpkg.com/sample-player/-/sample-player-0.5.5.tgz#bc35bea3449c6fa972528f022a9bbc2872195637"
|
||||
integrity sha512-VQ9pXPJ1m/eTH8QK6OQ8Dn/HSVToNyY9w9vnv+y/yjkJeRm87tJ/gBEm66jItfSLhKe6VG1DfX8+oT+Mg7QUpg==
|
||||
dependencies:
|
||||
adsr "^1.0.0"
|
||||
midimessage "^1.0.5"
|
||||
note-parser "^1.1.0"
|
||||
|
||||
sane@^4.0.3:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded"
|
||||
@@ -16944,6 +16938,11 @@ smart-buffer@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||
|
||||
smplr@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/smplr/-/smplr-0.6.1.tgz#f24cbe7ce3ad318bb6ce226d9aa933d1cab7dc56"
|
||||
integrity sha512-040QDtYRavqIje9346zWBYDc3oN/ARSZmheOGELAujQVYr3p4e8nrOsojH3VQsE0zcrAhjJ4MDeg74qIHQCC7A==
|
||||
|
||||
snapdragon-node@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
|
||||
@@ -17042,15 +17041,6 @@ socks@^2.6.2:
|
||||
ip "^2.0.0"
|
||||
smart-buffer "^4.2.0"
|
||||
|
||||
soundfont-player@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/soundfont-player/-/soundfont-player-0.12.0.tgz#2b26149f28aba471d2285d3df9a2e1e5793ceaf1"
|
||||
integrity sha512-8BJIsAt7h1PK3thSZDgF6zecgGhYkK74JnZO8WRZi3h34qG6H/DYlnv7cpRvL7Q9C8N6qld4Qwj7nJsX1gYjEA==
|
||||
dependencies:
|
||||
audio-loader "^0.5.0"
|
||||
note-parser "^2.0.0"
|
||||
sample-player "^0.5.5"
|
||||
|
||||
source-list-map@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
|
||||
@@ -17223,15 +17213,6 @@ stacktrace-parser@^0.1.3:
|
||||
dependencies:
|
||||
type-fest "^0.7.1"
|
||||
|
||||
standardized-audio-context@^25.3.51:
|
||||
version "25.3.51"
|
||||
resolved "https://registry.yarnpkg.com/standardized-audio-context/-/standardized-audio-context-25.3.51.tgz#0eb54629355d1ddf2070897e586eaa8dfec8c0f5"
|
||||
integrity sha512-+YPccvetw8wqWo0pv6lo5aDeUq+2WHL/S+8AWdrLKG1jMlhJZqK/GjNF/88q6jXAHal32Msc1xPx3uGrx8RPdQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.22.3"
|
||||
automation-events "^6.0.4"
|
||||
tslib "^2.5.3"
|
||||
|
||||
state-toggle@^1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe"
|
||||
@@ -18077,11 +18058,6 @@ tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1,
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
|
||||
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
|
||||
|
||||
tslib@^2.5.3:
|
||||
version "2.5.3"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913"
|
||||
integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==
|
||||
|
||||
tsutils@^3.21.0:
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:latest
|
||||
FROM python:3.10
|
||||
RUN wget -q -O /tmp/websocketd.zip \
|
||||
https://github.com/joewalnes/websocketd/releases/download/v0.4.1/websocketd-0.4.1-linux_amd64.zip \
|
||||
&& unzip /tmp/websocketd.zip -d /tmp/websocketd && mv /tmp/websocketd/websocketd /usr/bin \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:latest
|
||||
FROM python:3.10
|
||||
RUN wget -q -O /tmp/websocketd.zip \
|
||||
https://github.com/joewalnes/websocketd/releases/download/v0.4.1/websocketd-0.4.1-linux_amd64.zip \
|
||||
&& unzip /tmp/websocketd.zip -d /tmp/websocketd && mv /tmp/websocketd/websocketd /usr/bin \
|
||||
|
||||
21
shell.nix
Normal file
@@ -0,0 +1,21 @@
|
||||
{pkgs ? import <nixpkgs> {}}:
|
||||
pkgs.mkShell {
|
||||
nativeBuildInputs = [pkgs.bashInteractive];
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prisma
|
||||
nodePackages."@nestjs/cli"
|
||||
nodePackages.npm
|
||||
nodejs_16
|
||||
yarn
|
||||
python3
|
||||
pkg-config
|
||||
];
|
||||
shellHook = with pkgs; ''
|
||||
# export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
|
||||
# export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
|
||||
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
|
||||
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
|
||||
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
|
||||
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
|
||||
'';
|
||||
}
|
||||