Compare commits
6 Commits
feat/crawl
...
feat/pw-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f033fe78 | ||
| 869b2e696f | |||
| 050c970e7e | |||
| 5c83235cba | |||
| 3b2ca9963b | |||
| 0a08193418 |
@@ -8,6 +8,6 @@ insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = tab
|
||||
|
||||
[{*.yaml,*.yml,*.nix}]
|
||||
[{*.yaml,*.yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
@@ -7,11 +7,8 @@ JWT_SECRET=wow
|
||||
POSTGRES_DB=chromacase
|
||||
API_URL=http://localhost:80/api
|
||||
SCORO_URL=ws://localhost:6543
|
||||
MINIO_ROOT_PASSWORD=12345678
|
||||
|
||||
GOOGLE_CLIENT_ID=toto
|
||||
GOOGLE_SECRET=tata
|
||||
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
|
||||
SMTP_TRANSPORT=smtps://toto:tata@relay
|
||||
SMTP_TRANSPORT=
|
||||
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
|
||||
IGNORE_MAILS=true
|
||||
|
||||
2
.github/workflows/CI.yml
vendored
@@ -93,7 +93,7 @@ jobs:
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
docker-compose logs
|
||||
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
||||
|
||||
- name: Run scorometer tests
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
@@ -13,6 +13,3 @@ log.html
|
||||
node_modules/
|
||||
./front/coverage
|
||||
.venv
|
||||
.data
|
||||
.DS_Store
|
||||
_gen
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
CMD npx prisma migrate deploy; npm run start:prod
|
||||
CMD npx prisma migrate dev; npm run start:prod
|
||||
|
||||
5204
back/package-lock.json
generated
@@ -10,8 +10,8 @@
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch --preserveWatchOutput",
|
||||
"start:debug": "nest start --debug --watch --preserveWatchOutput",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
@@ -36,15 +36,12 @@
|
||||
"@types/passport": "^1.0.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"json-logger-service": "^9.0.1",
|
||||
"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",
|
||||
"prisma-class-generator": "^0.2.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LikedSongs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"songId" INTEGER NOT NULL,
|
||||
"addedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LikedSongs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LikedSongs" ADD CONSTRAINT "LikedSongs_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,8 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||
@@ -4,12 +4,6 @@ generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
generator prismaClassGenerator {
|
||||
provider = "prisma-class-generator"
|
||||
dryRun = false
|
||||
separateRelationFields = true
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
@@ -19,7 +13,7 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String?
|
||||
email String? @unique
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
@@ -28,16 +22,6 @@ model User {
|
||||
SongHistory SongHistory[]
|
||||
searchHistory SearchHistory[]
|
||||
settings UserSettings?
|
||||
likedSongs LikedSongs[]
|
||||
}
|
||||
|
||||
model LikedSongs {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
songId Int
|
||||
addedDate DateTime @default(now())
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
@@ -77,7 +61,6 @@ model Song {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
difficulties Json
|
||||
SongHistory SongHistory[]
|
||||
likedByUsers LikedSongs[]
|
||||
}
|
||||
|
||||
model SongHistory {
|
||||
|
||||
@@ -13,14 +13,13 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateAlbumDto } from './dto/create-album.dto';
|
||||
import { AlbumService } from './album.service';
|
||||
import { Request } from 'express';
|
||||
import { Prisma, Album } from '@prisma/client';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
import { Album as _Album } from 'src/_gen/prisma-class/album';
|
||||
|
||||
@Controller('album')
|
||||
@ApiTags('album')
|
||||
@@ -30,7 +29,6 @@ export class AlbumController {
|
||||
constructor(private readonly albumService: AlbumService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ description: "Register a new album, should not be used by frontend"})
|
||||
async create(@Body() createAlbumDto: CreateAlbumDto) {
|
||||
try {
|
||||
return await this.albumService.createAlbum({
|
||||
@@ -47,7 +45,6 @@ export class AlbumController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ description: "Delete an album by id"})
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.albumService.deleteAlbum({ id });
|
||||
@@ -57,8 +54,6 @@ export class AlbumController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Album)
|
||||
@ApiOperation({ description: "Get all albums paginated"})
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(AlbumController.filterableFields)
|
||||
@@ -75,8 +70,6 @@ export class AlbumController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ description: "Get an album by id"})
|
||||
@ApiOkResponse({ type: _Album})
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.albumService.album({ id });
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ description: 'Return a hello world message, used as a health route' })
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@@ -15,15 +15,14 @@ import {
|
||||
Req,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateArtistDto } from './dto/create-artist.dto';
|
||||
import { Request } from 'express';
|
||||
import { ArtistService } from './artist.service';
|
||||
import { Prisma, Artist } from '@prisma/client';
|
||||
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
import { Artist as _Artist} from 'src/_gen/prisma-class/artist';
|
||||
|
||||
@Controller('artist')
|
||||
@ApiTags('artist')
|
||||
@@ -33,7 +32,6 @@ export class ArtistController {
|
||||
constructor(private readonly service: ArtistService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ description: "Register a new artist, should not be used by frontend"})
|
||||
async create(@Body() dto: CreateArtistDto) {
|
||||
try {
|
||||
return await this.service.create(dto);
|
||||
@@ -43,7 +41,6 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ description: "Delete an artist by id"})
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.service.delete({ id });
|
||||
@@ -53,8 +50,6 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
@ApiOperation({ description: "Get an artist's illustration"})
|
||||
@ApiNotFoundResponse({ description: "Artist or illustration not found"})
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
const artist = await this.service.get({ id });
|
||||
if (!artist) throw new NotFoundException('Artist not found');
|
||||
@@ -71,8 +66,6 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ description: "Get all artists paginated"})
|
||||
@ApiOkResponsePlaginated(_Artist)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(ArtistController.filterableFields)
|
||||
@@ -89,8 +82,6 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ description: "Get an artist by id"})
|
||||
@ApiOkResponse({ type: _Artist})
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.service.get({ id });
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpCode,
|
||||
Put,
|
||||
InternalServerErrorException,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Query,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
@@ -28,15 +26,9 @@ import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
@@ -50,7 +42,6 @@ import { SettingsService } from 'src/settings/settings.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { writeFile } from 'fs';
|
||||
import { PasswordResetDto } from './dto/password_reset.dto ';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -63,11 +54,9 @@ export class AuthController {
|
||||
|
||||
@Get('login/google')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@ApiOperation({description: 'Redirect to google login page'})
|
||||
googleLogin() {}
|
||||
|
||||
@Get('logged/google')
|
||||
@ApiOperation({description: 'Redirect to the front page after connecting to the google account'})
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleLoginCallbakc(@Req() req: any) {
|
||||
let user = await this.usersService.user({ googleID: req.user.googleID });
|
||||
@@ -79,82 +68,59 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({description: 'Register a new user'})
|
||||
@ApiConflictResponse({ description: 'Username or email already taken' })
|
||||
@ApiOkResponse({ description: 'Successfully registered, email sent to verify' })
|
||||
@ApiBadRequestResponse({ description: 'Invalid data or database error' })
|
||||
async register(@Body() registerDto: RegisterDto): Promise<void> {
|
||||
try {
|
||||
const user = await this.usersService.createUser(registerDto);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
await this.authService.sendVerifyMail(user);
|
||||
} catch (e) {
|
||||
// check if the error is a duplicate key error
|
||||
if (e.code === 'P2002') {
|
||||
throw new ConflictException('Username or email already taken');
|
||||
}
|
||||
console.error(e);
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
@Put('verify')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({description: 'Verify the email of the user'})
|
||||
@ApiOkResponse({ description: 'Successfully verified' })
|
||||
@ApiBadRequestResponse({ description: 'Invalid or expired token' })
|
||||
@Put('reset')
|
||||
async password_reset(@Request() req: any, @Query('token') token: string): Promise<void> {
|
||||
if (await this.authService.resetPassword(req.user.id, token))
|
||||
return;
|
||||
throw new BadRequestException("Invalid token. Expired or invalid.");
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('send-reset')
|
||||
async send_reset(@Request() req: any): Promise<void> {
|
||||
await this.authService.sendResetMail(req.user);
|
||||
}
|
||||
|
||||
@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.");
|
||||
}
|
||||
|
||||
@Put('reverify')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(200)
|
||||
@ApiOperation({description: 'Resend the verification email'})
|
||||
@Put('reverify')
|
||||
async reverify(@Request() req: any): Promise<void> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new BadRequestException('Invalid user');
|
||||
await this.authService.sendVerifyMail(user);
|
||||
await this.authService.sendVerifyMail(req.user);
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put('password-reset')
|
||||
async password_reset(
|
||||
@Body() resetDto: PasswordResetDto,
|
||||
@Query('token') token: string,
|
||||
): Promise<void> {
|
||||
if (await this.authService.changePassword(resetDto.password, token)) return;
|
||||
throw new BadRequestException('Invalid token. Expired or invalid.');
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Put('forgot-password')
|
||||
async forgot_password(@Query('email') email: string): Promise<void> {
|
||||
console.log(email);
|
||||
const user = await this.usersService.user({ email: email });
|
||||
if (!user) throw new BadRequestException('Invalid user');
|
||||
await this.authService.sendPasswordResetMail(user);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiBody({ type: LoginDto })
|
||||
@HttpCode(200)
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiOperation({ description: 'Login with username and password' })
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
@Post('login')
|
||||
async login(@Request() req: any): Promise<JwtToken> {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@Post('guest')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: 'Login as a guest account' })
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
|
||||
@Post('guest')
|
||||
async guest(): Promise<JwtToken> {
|
||||
const user = await this.usersService.createGuest();
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
@@ -163,7 +129,6 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ description: 'Get the profile picture of connected user' })
|
||||
@ApiOkResponse({ description: 'The user profile picture' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/picture')
|
||||
@@ -176,7 +141,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'The user profile picture' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Post('me/picture')
|
||||
@ApiOperation({ description: 'Upload a new profile picture' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async postProfilePicture(
|
||||
@Request() req: any,
|
||||
@@ -202,7 +166,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me')
|
||||
@ApiOperation({ description: 'Get the user info of connected user' })
|
||||
async getProfile(@Request() req: any): Promise<User> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
@@ -214,7 +177,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully edited profile', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Put('me')
|
||||
@ApiOperation({ description: 'Edit the profile of connected user' })
|
||||
editProfile(
|
||||
@Request() req: any,
|
||||
@Body() profile: Partial<Profile>,
|
||||
@@ -240,7 +202,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully deleted', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Delete('me')
|
||||
@ApiOperation({ description: 'Delete the profile of connected user' })
|
||||
deleteSelf(@Request() req: any): Promise<User> {
|
||||
return this.usersService.deleteUser({ id: req.user.id });
|
||||
}
|
||||
@@ -250,7 +211,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Patch('me/settings')
|
||||
@ApiOperation({ description: 'Edit the settings of connected user' })
|
||||
udpateSettings(
|
||||
@Request() req: any,
|
||||
@Body() settingUserDto: UpdateSettingDto,
|
||||
@@ -266,7 +226,6 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/settings')
|
||||
@ApiOperation({ description: 'Get the settings of connected user' })
|
||||
async getSettings(@Request() req: any): Promise<Setting> {
|
||||
const result = await this.settingsService.getUserSetting({
|
||||
userId: +req.user.id,
|
||||
@@ -274,45 +233,4 @@ export class AuthController {
|
||||
if (!result) throw new NotFoundException();
|
||||
return result;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully added liked song'})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Post('me/likes/:id')
|
||||
addLikedSong(
|
||||
@Request() req: any,
|
||||
@Param('id') songId: number
|
||||
) {
|
||||
return this.usersService.addLikedSong(
|
||||
+req.user.id,
|
||||
+songId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully removed liked song'})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Delete('me/likes/:id')
|
||||
removeLikedSong(
|
||||
@Request() req: any,
|
||||
@Param('id') songId: number,
|
||||
) {
|
||||
return this.usersService.removeLikedSong(
|
||||
+req.user.id,
|
||||
+songId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully retrieved liked song'})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/likes')
|
||||
getLikedSongs(
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.usersService.getLikedSongs(+req.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { GoogleStrategy } from './google.strategy';
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '365d' },
|
||||
signOptions: { expiresIn: '1h' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
@@ -36,9 +36,6 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async sendVerifyMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === 'true') return;
|
||||
if (user.email == null) return;
|
||||
console.log('Sending verification mail to', user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
@@ -49,49 +46,15 @@ export class AuthService {
|
||||
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>.`,
|
||||
html: `To verify your mail, please click on this <a href="{${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordResetMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === 'true') return;
|
||||
if (user.email == null) return;
|
||||
console.log('Sending password reset mail to', user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: '10h' },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: 'chromacase@octohub.app',
|
||||
subject: 'Password reset for Chromacase',
|
||||
html: `To reset your password, please click on this <a href="${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(new_password: string, token: string): Promise<boolean> {
|
||||
let verified;
|
||||
try {
|
||||
verified = await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log('Password reset token failure', e);
|
||||
return false;
|
||||
}
|
||||
console.log(verified)
|
||||
await this.userService.updateUser({
|
||||
where: { id: verified.userId },
|
||||
data: { password: new_password },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyMail(userId: number, token: string): Promise<boolean> {
|
||||
try {
|
||||
await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log('Verify mail token failure', e);
|
||||
} catch(e) {
|
||||
console.log("Verify mail token failure", e);
|
||||
return false;
|
||||
}
|
||||
await this.userService.updateUser({
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PasswordResetDto {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Req,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateGenreDto } from './dto/create-genre.dto';
|
||||
import { Request } from 'express';
|
||||
import { GenreService } from './genre.service';
|
||||
@@ -22,7 +22,6 @@ import { Prisma, Genre } from '@prisma/client';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
|
||||
|
||||
@Controller('genre')
|
||||
@ApiTags('genre')
|
||||
@@ -66,7 +65,6 @@ export class GenreController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Genre)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(GenreController.filterableFields)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SearchHistoryDto {
|
||||
@ApiProperty()
|
||||
query: string;
|
||||
|
||||
@ApiProperty()
|
||||
type: 'song' | 'artist' | 'album' | 'genre';
|
||||
type: "song" | "artist" | "album" | "genre";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNumber } from "class-validator";
|
||||
|
||||
export class SongHistoryDto {
|
||||
@ApiProperty()
|
||||
@@ -15,8 +15,8 @@ export class SongHistoryDto {
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
difficulties: Record<string, number>;
|
||||
difficulties: Record<string, number>
|
||||
|
||||
@ApiProperty()
|
||||
info: Record<string, number>;
|
||||
info: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -10,25 +10,21 @@ import {
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { SearchHistory, SongHistory } from '@prisma/client';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||
import { HistoryService } from './history.service';
|
||||
import { SearchHistoryDto } from './dto/SearchHistoryDto';
|
||||
import { SongHistory as _SongHistory } from 'src/_gen/prisma-class/song_history';
|
||||
import { SearchHistory as _SearchHistory} from 'src/_gen/prisma-class/search_history';
|
||||
|
||||
@Controller('history')
|
||||
@ApiTags('history')
|
||||
export class HistoryController {
|
||||
constructor(private readonly historyService: HistoryService) {}
|
||||
constructor(private readonly historyService: HistoryService) { }
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: "Get song history of connected user"})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: _SongHistory, isArray: true})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async getHistory(
|
||||
@Request() req: any,
|
||||
@@ -40,9 +36,7 @@ export class HistoryController {
|
||||
|
||||
@Get('search')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ description: "Get search history of connected user"})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: _SearchHistory, isArray: true})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async getSearchHistory(
|
||||
@Request() req: any,
|
||||
@@ -54,24 +48,18 @@ export class HistoryController {
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
@ApiOperation({ description: "Create a record of a song played by a user"})
|
||||
@ApiCreatedResponse({ description: "Succesfully created a record"})
|
||||
async create(@Body() record: SongHistoryDto): Promise<SongHistory> {
|
||||
return this.historyService.createSongHistoryRecord(record);
|
||||
}
|
||||
|
||||
@Post('search')
|
||||
@Post("search")
|
||||
@HttpCode(201)
|
||||
@ApiOperation({ description: "Creates a search record in the users history"})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@ApiUnauthorizedResponse({description: "Invalid token"})
|
||||
async createSearchHistory(
|
||||
@Request() req: any,
|
||||
@Body() record: SearchHistoryDto,
|
||||
): Promise<void> {
|
||||
await this.historyService.createSearchHistoryRecord(req.user.id, {
|
||||
query: record.query,
|
||||
type: record.type,
|
||||
});
|
||||
}
|
||||
@Body() record: SearchHistoryDto
|
||||
): Promise<void> {
|
||||
await this.historyService.createSearchHistoryRecord(req.user.id, { query: record.query, type: record.type });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
describe('HistoryService', () => {
|
||||
let service: HistoryService;
|
||||
let service: HistoryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private prisma: PrismaService) { }
|
||||
|
||||
async createSongHistoryRecord({
|
||||
songID,
|
||||
@@ -74,7 +74,7 @@ export class HistoryService {
|
||||
|
||||
async createSearchHistoryRecord(
|
||||
userID: number,
|
||||
{ query, type }: SearchHistoryDto,
|
||||
{ query, type }: SearchHistoryDto
|
||||
): Promise<SearchHistory> {
|
||||
return this.prisma.searchHistory.create({
|
||||
data: {
|
||||
|
||||
@@ -13,12 +13,11 @@ import {
|
||||
Delete,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { LessonService } from './lesson.service';
|
||||
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import { Prisma, Skill } from '@prisma/client';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
import { Lesson as _Lesson} from 'src/_gen/prisma-class/lesson';
|
||||
|
||||
export class Lesson {
|
||||
@ApiProperty()
|
||||
@@ -49,7 +48,6 @@ export class LessonController {
|
||||
summary: 'Get all lessons',
|
||||
})
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Lesson)
|
||||
async getAll(
|
||||
@Req() request: Request,
|
||||
@FilterQuery(LessonController.filterableFields)
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { RequestLogger, RequestLoggerOptions } from 'json-logger-service';
|
||||
import { tap } from 'rxjs';
|
||||
import { PrismaModel } from './_gen/prisma-class'
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AspectLogger implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const res = context.switchToHttp().getResponse();
|
||||
const { statusCode } = context.switchToHttp().getResponse();
|
||||
const { originalUrl, method, params, query, body, user } = req;
|
||||
|
||||
const toPrint = {
|
||||
originalUrl,
|
||||
method,
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
userId: user?.id ?? 'not logged in',
|
||||
username: user?.username ?? 'not logged in',
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) =>
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...toPrint,
|
||||
statusCode,
|
||||
data,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.use(
|
||||
RequestLogger.buildExpressRequestLogger({
|
||||
doNotLogPaths: ['/health'],
|
||||
} as RequestLoggerOptions),
|
||||
);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
@@ -59,13 +12,11 @@ async function bootstrap() {
|
||||
.setDescription('The chromacase API')
|
||||
.setVersion('1.0')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config, { extraModels: [...PrismaModel.extraModels]});
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.enableCors();
|
||||
app.useGlobalInterceptors(new AspectLogger());
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
|
||||
*/
|
||||
|
||||
import { Type, applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PlageMetadata {
|
||||
export class Plage<T> {
|
||||
@ApiProperty()
|
||||
this: string;
|
||||
@ApiProperty({ type: "string", nullable: true, description: "null if there is no next page, couldn't set it in swagger"})
|
||||
next: string | null;
|
||||
@ApiProperty({ type: "string", nullable: true, description: "null if there is no previous page, couldn't set it in swagger" })
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
export class Plage<T extends object> {
|
||||
metadata: {
|
||||
this: string;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
};
|
||||
@ApiProperty()
|
||||
metadata: PlageMetadata;
|
||||
data: T[];
|
||||
|
||||
constructor(data: T[], request: Request | any) {
|
||||
@@ -54,23 +49,3 @@ export class Plage<T extends object> {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiOkResponsePlaginated = <DataDto extends Type<unknown>>(dataDto: DataDto) =>
|
||||
applyDecorators(
|
||||
ApiExtraModels(Plage, dataDto),
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(Plage) },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(dataDto) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -6,7 +6,7 @@ export class User {
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
@ApiProperty()
|
||||
email: string | null;
|
||||
email: string;
|
||||
@ApiProperty()
|
||||
isGuest: boolean;
|
||||
@ApiProperty()
|
||||
|
||||
@@ -12,29 +12,20 @@ import {
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import { Artist, Genre, Song } from '@prisma/client';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { SearchSongDto } from './dto/search-song.dto';
|
||||
import { SearchService } from './search.service';
|
||||
import { Song as _Song } from 'src/_gen/prisma-class/song';
|
||||
import { Genre as _Genre } from 'src/_gen/prisma-class/genre';
|
||||
import { Artist as _Artist } from 'src/_gen/prisma-class/artist';
|
||||
|
||||
@ApiTags('search')
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
constructor(private readonly searchService: SearchService) { }
|
||||
|
||||
@Get('songs/:query')
|
||||
@ApiOkResponse({ type: _Song, isArray: true})
|
||||
@ApiOperation({ description: "Search a song"})
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token"})
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchSong(
|
||||
@Request() req: any,
|
||||
@Param('query') query: string,
|
||||
): Promise<Song[] | null> {
|
||||
async searchSong(@Request() req: any, @Param('query') query: string): Promise<Song[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.songByGuess(query, req.user?.id);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
@@ -46,9 +37,6 @@ export class SearchController {
|
||||
|
||||
@Get('genres/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token"})
|
||||
@ApiOkResponse({ type: _Genre, isArray: true})
|
||||
@ApiOperation({ description: "Search a genre"})
|
||||
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.genreByGuess(query, req.user?.id);
|
||||
@@ -61,9 +49,6 @@ export class SearchController {
|
||||
|
||||
@Get('artists/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: _Artist, isArray: true})
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token"})
|
||||
@ApiOperation({ description: "Search an artist"})
|
||||
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.artistByGuess(query, req.user?.id);
|
||||
@@ -73,4 +58,4 @@ export class SearchController {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private history: HistoryService,
|
||||
) {}
|
||||
constructor(private prisma: PrismaService, private history: HistoryService) { }
|
||||
|
||||
async songByGuess(query: string, userID: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SettingsService } from './settings.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
|
||||
@@ -20,10 +20,10 @@ export class SettingsService {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async updateUserSettings(params: {
|
||||
@@ -37,9 +37,7 @@ export class SettingsService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUserSettings(
|
||||
where: Prisma.UserSettingsWhereUniqueInput,
|
||||
): Promise<UserSettings> {
|
||||
async deleteUserSettings(where: Prisma.UserSettingsWhereUniqueInput): Promise<UserSettings> {
|
||||
return this.prisma.userSettings.delete({
|
||||
where,
|
||||
});
|
||||
|
||||
@@ -16,26 +16,16 @@ import {
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
|
||||
import { Plage } from 'src/models/plage';
|
||||
import { CreateSongDto } from './dto/create-song.dto';
|
||||
import { SongService } from './song.service';
|
||||
import { Request } from 'express';
|
||||
import { Prisma, Song } from '@prisma/client';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiProperty, ApiResponse, ApiResponseProperty, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||
import { HistoryService } from 'src/history/history.service';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { FilterQuery } from 'src/utils/filter.pipe';
|
||||
import { Song as _Song } from 'src/_gen/prisma-class/song';
|
||||
import { SongHistory } from 'src/_gen/prisma-class/song_history';
|
||||
|
||||
|
||||
class SongHistoryResult {
|
||||
@ApiProperty()
|
||||
best: number;
|
||||
@ApiProperty({ type: SongHistory, isArray: true})
|
||||
history: SongHistory[];
|
||||
}
|
||||
|
||||
@Controller('song')
|
||||
@ApiTags('song')
|
||||
@@ -54,9 +44,6 @@ export class SongController {
|
||||
) {}
|
||||
|
||||
@Get(':id/midi')
|
||||
@ApiOperation({ description: "Streams the midi file of the requested song"})
|
||||
@ApiNotFoundResponse({ description: "Song not found"})
|
||||
@ApiOkResponse({ description: "Returns the midi file succesfully"})
|
||||
async getMidi(@Param('id', ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
@@ -70,9 +57,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
@ApiOperation({ description: "Streams the illustration of the requested song"})
|
||||
@ApiNotFoundResponse({ description: "Song not found"})
|
||||
@ApiOkResponse({ description: "Returns the illustration succesfully"})
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
@@ -90,9 +74,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Get(':id/musicXml')
|
||||
@ApiOperation({ description: "Streams the musicXML file of the requested song"})
|
||||
@ApiNotFoundResponse({ description: "Song not found"})
|
||||
@ApiOkResponse({ description: "Returns the musicXML file succesfully"})
|
||||
async getMusicXml(@Param('id', ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
@@ -102,7 +83,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({description: "register a new song in the database, should not be used by the frontend"})
|
||||
async create(@Body() createSongDto: CreateSongDto) {
|
||||
try {
|
||||
return await this.songService.createSong({
|
||||
@@ -118,7 +98,6 @@ export class SongController {
|
||||
: undefined,
|
||||
});
|
||||
} catch {
|
||||
|
||||
throw new ConflictException(
|
||||
await this.songService.song({ name: createSongDto.name }),
|
||||
);
|
||||
@@ -126,7 +105,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ description: "delete a song by id"})
|
||||
async remove(@Param('id', ParseIntPipe) id: number) {
|
||||
try {
|
||||
return await this.songService.deleteSong({ id });
|
||||
@@ -136,7 +114,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Song)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
|
||||
@@ -152,9 +129,6 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ description: "Get a specific song data"})
|
||||
@ApiNotFoundResponse({ description: "Song not found"})
|
||||
@ApiOkResponse({ type: _Song, description: "Requested song"})
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.songService.song({ id });
|
||||
|
||||
@@ -165,8 +139,6 @@ export class SongController {
|
||||
@Get(':id/history')
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ description: "get the history of the connected user on a specific song"})
|
||||
@ApiOkResponse({ type: SongHistoryResult, description: "Records of previous games of the user"})
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async getHistory(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.historyService.getForSong({
|
||||
|
||||
@@ -6,14 +6,6 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
||||
export class SongService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async songByArtist(data: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
artistId: {equals: data},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createSong(data: Prisma.SongCreateInput): Promise<Song> {
|
||||
return this.prisma.song.create({
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Post, Param, NotFoundException, Response } from '@nestjs/common';
|
||||
import { Controller, Get, Param, NotFoundException, Response } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from 'src/models/user';
|
||||
|
||||
@ApiTags('users')
|
||||
@@ -22,7 +22,6 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Get(':id/picture')
|
||||
@ApiOkResponse({description: 'Return the profile picture of the requested user'})
|
||||
async getPicture(@Response() res: any, @Param('id') id: number) {
|
||||
return await this.usersService.getProfilePicture(+id, res);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
@@ -54,7 +53,7 @@ export class UsersService {
|
||||
username: `Guest ${randomUUID()}`,
|
||||
isGuest: true,
|
||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||
email: null,
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
@@ -90,7 +89,6 @@ export class UsersService {
|
||||
// We could not find a profile icon locally, using gravatar instead.
|
||||
const user = await this.user({ id: userId });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
if (!user.email) throw new NotFoundException();
|
||||
const hash = createHash('md5')
|
||||
.update(user.email.trim().toLowerCase())
|
||||
.digest('hex');
|
||||
@@ -100,36 +98,4 @@ export class UsersService {
|
||||
for (const [k, v] of resp.headers) resp.headers.set(k, v);
|
||||
resp.body!.pipe(res);
|
||||
}
|
||||
|
||||
async addLikedSong(
|
||||
userId: number,
|
||||
songId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.create(
|
||||
{
|
||||
data: { songId: songId, userId: userId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async getLikedSongs(
|
||||
userId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.findMany(
|
||||
{
|
||||
where: { userId: userId },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async removeLikedSong(
|
||||
userId: number,
|
||||
songId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.deleteMany(
|
||||
{
|
||||
where: { userId: userId, songId: songId },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ Register Duplicates
|
||||
# We can't use the `Register` keyword because it assert for success
|
||||
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
|
||||
Output
|
||||
Integer response status 409
|
||||
Integer response status 400
|
||||
Login user-duplicate
|
||||
[Teardown] DELETE /auth/me
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
user nginx;
|
||||
worker_processes 5; ## Default: 1
|
||||
events {
|
||||
worker_connections 1000;
|
||||
}
|
||||
http {
|
||||
resolver 127.0.0.11;
|
||||
server {
|
||||
listen 3100;
|
||||
location = / {
|
||||
return 200 'OK';
|
||||
auth_basic off;
|
||||
}
|
||||
location = /api/prom/push {
|
||||
proxy_pass http://write:3100\$$request_uri;
|
||||
}
|
||||
location = /api/prom/tail {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
proxy_set_header Upgrade \$$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
location ~ /api/prom/.* {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
}
|
||||
location = /loki/api/v1/push {
|
||||
proxy_pass http://write:3100\$$request_uri;
|
||||
}
|
||||
location = /loki/api/v1/tail {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
proxy_set_header Upgrade \$$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
location ~ /loki/api/.* {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
auth_enabled: false
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
memberlist:
|
||||
join_members:
|
||||
- loki:7946
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2021-08-01
|
||||
store: boltdb-shipper
|
||||
object_store: s3
|
||||
schema: v11
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
common:
|
||||
path_prefix: /loki
|
||||
replication_factor: 1
|
||||
storage:
|
||||
s3:
|
||||
endpoint: minio:9000
|
||||
insecure: true
|
||||
bucketnames: loki-data
|
||||
access_key_id: loki
|
||||
secret_access_key: 12345678
|
||||
s3forcepathstyle: true
|
||||
ring:
|
||||
kvstore:
|
||||
store: memberlist
|
||||
query_range:
|
||||
parallelise_shardable_queries: false
|
||||
ruler:
|
||||
storage:
|
||||
s3:
|
||||
bucketnames: loki-ruler
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://gateway:3100/loki/api/v1/push
|
||||
tenant_id: tenant1
|
||||
|
||||
scrape_configs:
|
||||
- job_name: flog_scrape
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
relabel_configs:
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/(.*)'
|
||||
target_label: 'container'
|
||||
|
||||
29
crawler/package-lock.json
generated
@@ -11,12 +11,10 @@
|
||||
"dependencies": {
|
||||
"crawlee": "^3.0.0",
|
||||
"fs": "^0.0.1-security",
|
||||
"playwright": "^1.28.0",
|
||||
"slug": "^8.2.3"
|
||||
"playwright": "^1.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apify/tsconfig": "^0.1.0",
|
||||
"@types/slug": "^5.0.5",
|
||||
"ts-node": "^10.8.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
@@ -780,12 +778,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/slug": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/slug/-/slug-5.0.5.tgz",
|
||||
"integrity": "sha512-vcHM79Xu5ALOC90kf5S1B4XGbRl8VW6f1+6jpBmK/FLHi4AyWKAVENgMOyHFyjHV5vDbNRPtjsNJuPRqrLBOxw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
|
||||
@@ -2768,14 +2760,6 @@
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
},
|
||||
"node_modules/slug": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/slug/-/slug-8.2.3.tgz",
|
||||
"integrity": "sha512-fXjhAZszNecz855GUNIwW0+sFPi9WV4bMiEKDOCA4wcq1ts1UnUVNy/F78B0Aat7/W3rA+se//33ILKNMrbeYQ==",
|
||||
"bin": {
|
||||
"slug": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -3864,12 +3848,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/slug": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/slug/-/slug-5.0.5.tgz",
|
||||
"integrity": "sha512-vcHM79Xu5ALOC90kf5S1B4XGbRl8VW6f1+6jpBmK/FLHi4AyWKAVENgMOyHFyjHV5vDbNRPtjsNJuPRqrLBOxw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/tough-cookie": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
|
||||
@@ -5255,11 +5233,6 @@
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
},
|
||||
"slug": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/slug/-/slug-8.2.3.tgz",
|
||||
"integrity": "sha512-fXjhAZszNecz855GUNIwW0+sFPi9WV4bMiEKDOCA4wcq1ts1UnUVNy/F78B0Aat7/W3rA+se//33ILKNMrbeYQ=="
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
"dependencies": {
|
||||
"crawlee": "^3.0.0",
|
||||
"fs": "^0.0.1-security",
|
||||
"playwright": "^1.28.0",
|
||||
"slug": "^8.2.3"
|
||||
"playwright": "^1.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apify/tsconfig": "^0.1.0",
|
||||
"@types/slug": "^5.0.5",
|
||||
"ts-node": "^10.8.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
|
||||
@@ -17,5 +17,5 @@ const crawler = new PlaywrightCrawler({
|
||||
|
||||
// Add first URL to the queue and start the crawl.
|
||||
await crawler.run([
|
||||
"https://musescore.com/sheetmusic?complexity=1&instrument=2&instrumentation=114&license=to_modify_commercially%2Cto_use_commercially&recording_type=public-domain&sort=rating",
|
||||
"https://musescore.com/sheetmusic?complexity=1&instrument=2&license=to_modify_commercially%2Cto_use_commercially&recording_type=public-domain",
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Dataset, createPlaywrightRouter } from "crawlee";
|
||||
import * as fs from "fs";
|
||||
import { sleep } from "crawlee";
|
||||
export const router = createPlaywrightRouter();
|
||||
import slug from "slug";
|
||||
|
||||
router.addDefaultHandler(async ({ enqueueLinks }) => {
|
||||
const songs = await enqueueLinks({
|
||||
@@ -19,17 +18,13 @@ router.addDefaultHandler(async ({ enqueueLinks }) => {
|
||||
router.addHandler("SONG", async ({ request, page }) => {
|
||||
await Dataset.pushData({ url: request.loadedUrl });
|
||||
await page.waitForSelector('aside div div section button[name="download"]');
|
||||
let og_title = await page.locator("h1").textContent();
|
||||
if (og_title == null) return
|
||||
let title = slug(og_title);
|
||||
let artist = await page
|
||||
const title = await page.locator("h1").textContent();
|
||||
const artist = await page
|
||||
.locator(
|
||||
"body > div.js-page.react-container > div > section > aside > div:nth-child(5) > div > section > h3:nth-child(2) > a"
|
||||
)
|
||||
.first()
|
||||
.textContent();
|
||||
if (artist == null) return
|
||||
artist = slug(artist);
|
||||
const genres = await page
|
||||
.locator(
|
||||
"body > div.js-page.react-container > div > section > aside > div:nth-child(6) > div > table > tbody > tr:nth-child(5) > td > div > a"
|
||||
@@ -71,7 +66,7 @@ router.addHandler("SONG", async ({ request, page }) => {
|
||||
`../musics/a/${title}/${title}.ini`,
|
||||
`
|
||||
[Metadata]
|
||||
Name=${og_title}
|
||||
Name=${title}
|
||||
Artist=${artist}
|
||||
Genre=${genres}
|
||||
Album=
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
volumes:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build:
|
||||
@@ -32,9 +25,6 @@ services:
|
||||
volumes:
|
||||
- ./scorometer:/app
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
networks:
|
||||
- loki
|
||||
|
||||
db:
|
||||
container_name: db
|
||||
@@ -50,14 +40,13 @@ services:
|
||||
retries: 5
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
environment:
|
||||
- SCOROMETER_URL=http://scorometer:6543/
|
||||
- NGINX_PORT=4567
|
||||
- NGINX_PORT=80
|
||||
ports:
|
||||
- "19006:19006"
|
||||
volumes:
|
||||
@@ -66,19 +55,3 @@ services:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
environment:
|
||||
- API_URL=http://back:3000
|
||||
- SCOROMETER_URL=http://scorometer:6543
|
||||
- FRONT_URL=http://front:19006
|
||||
- PORT=4567
|
||||
depends_on:
|
||||
- back
|
||||
- front
|
||||
volumes:
|
||||
- "./front/assets:/assets:ro"
|
||||
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
|
||||
ports:
|
||||
- "4567:4567"
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
services:
|
||||
read:
|
||||
image: grafana/loki:2.8.2
|
||||
command: "-config.file=/etc/loki/config.yaml -target=read"
|
||||
ports:
|
||||
- 3101:3100
|
||||
- 7946
|
||||
- 9095
|
||||
volumes:
|
||||
- ./config/loki-config.yaml:/etc/loki/config.yaml
|
||||
depends_on:
|
||||
- minio
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks: &loki-dns
|
||||
loki:
|
||||
aliases:
|
||||
- loki
|
||||
|
||||
write:
|
||||
image: grafana/loki:2.8.2
|
||||
command: "-config.file=/etc/loki/config.yaml -target=write"
|
||||
ports:
|
||||
- 3102:3100
|
||||
- 7946
|
||||
- 9095
|
||||
volumes:
|
||||
- ./config/loki-config.yaml:/etc/loki/config.yaml
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
depends_on:
|
||||
- minio
|
||||
networks:
|
||||
<<: *loki-dns
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:2.8.2
|
||||
volumes:
|
||||
- ./config/promtail-local-config.yaml:/etc/promtail/config.yaml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
depends_on:
|
||||
- gateway
|
||||
networks:
|
||||
- loki
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2023-07-21T21-12-44Z
|
||||
entrypoint:
|
||||
- sh
|
||||
- -euc
|
||||
- |
|
||||
mkdir -p /data/loki-data && \
|
||||
mkdir -p /data/loki-ruler && \
|
||||
minio server /data
|
||||
environment:
|
||||
- MINIO_ROOT_USER=loki
|
||||
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
|
||||
- MINIO_PROMETHEUS_AUTH_TYPE=public
|
||||
- MINIO_UPDATE=off
|
||||
ports:
|
||||
- 9000
|
||||
volumes:
|
||||
- ./.data/minio:/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ]
|
||||
interval: 15s
|
||||
timeout: 20s
|
||||
retries: 5
|
||||
networks:
|
||||
- loki
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:9.5.6
|
||||
environment:
|
||||
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||
depends_on:
|
||||
- gateway
|
||||
entrypoint:
|
||||
- sh
|
||||
- -euc
|
||||
- |
|
||||
mkdir -p /etc/grafana/provisioning/datasources
|
||||
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://gateway:3100
|
||||
jsonData:
|
||||
httpHeaderName1: "X-Scope-OrgID"
|
||||
secureJsonData:
|
||||
httpHeaderValue1: "tenant1"
|
||||
EOF
|
||||
/run.sh
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./grafana/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- loki
|
||||
|
||||
gateway:
|
||||
image: nginx:1.25.1
|
||||
depends_on:
|
||||
- read
|
||||
- write
|
||||
entrypoint:
|
||||
- sh
|
||||
- -euc
|
||||
- |
|
||||
cat <<EOF > /etc/nginx/nginx.conf
|
||||
user nginx;
|
||||
worker_processes 5; ## Default: 1
|
||||
|
||||
events {
|
||||
worker_connections 1000;
|
||||
}
|
||||
|
||||
http {
|
||||
resolver 127.0.0.11;
|
||||
|
||||
server {
|
||||
listen 3100;
|
||||
|
||||
location = / {
|
||||
return 200 'OK';
|
||||
auth_basic off;
|
||||
}
|
||||
|
||||
location = /api/prom/push {
|
||||
proxy_pass http://write:3100\$$request_uri;
|
||||
}
|
||||
|
||||
location = /api/prom/tail {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
proxy_set_header Upgrade \$$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ~ /api/prom/.* {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
}
|
||||
|
||||
location = /loki/api/v1/push {
|
||||
proxy_pass http://write:3100\$$request_uri;
|
||||
}
|
||||
|
||||
location = /loki/api/v1/tail {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
proxy_set_header Upgrade \$$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ~ /loki/api/.* {
|
||||
proxy_pass http://read:3100\$$request_uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
/docker-entrypoint.sh nginx -g "daemon off;"
|
||||
ports:
|
||||
- "3100:3100"
|
||||
healthcheck:
|
||||
test: ["CMD", "service", "nginx", "status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- loki
|
||||
@@ -1,9 +1,3 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
volumes:
|
||||
scoro_logs:
|
||||
|
||||
services:
|
||||
back:
|
||||
image: ghcr.io/chroma-case/back:main
|
||||
@@ -22,14 +16,13 @@ services:
|
||||
ports:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- scoro_logs:/logs
|
||||
- ./assets:/assets
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORDgrafana}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
@@ -50,4 +43,4 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
- .env
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
|
||||
volumes:
|
||||
db:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build: ./back
|
||||
@@ -26,7 +17,6 @@ services:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
@@ -45,7 +35,11 @@ services:
|
||||
retries: 5
|
||||
|
||||
front:
|
||||
build: ./front
|
||||
build:
|
||||
context: ./front
|
||||
args:
|
||||
- API_URL=${API_URL}
|
||||
- SCORO_URL=${SCORO_URL}
|
||||
environment:
|
||||
- API_URL=http://back:3000/
|
||||
- SCOROMETER_URL=http://scorometer:6543/
|
||||
@@ -55,4 +49,7 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
.idea/
|
||||
.vscode/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.vscode/
|
||||
2
front/.gitignore
vendored
@@ -14,7 +14,5 @@ yarn.error*
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
yarn-error.log
|
||||
|
||||
.idea/
|
||||
.expo
|
||||
101
front/API.ts
@@ -4,10 +4,10 @@ import Chapter from './models/Chapter';
|
||||
import Lesson from './models/Lesson';
|
||||
import Genre, { GenreHandler } from './models/Genre';
|
||||
import LessonHistory from './models/LessonHistory';
|
||||
import likedSong, { LikedSongHandler } from './models/LikedSong';
|
||||
import Song, { SongHandler } from './models/Song';
|
||||
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
|
||||
import User, { UserHandler } from './models/User';
|
||||
import Constants from 'expo-constants';
|
||||
import store from './state/Store';
|
||||
import { Platform } from 'react-native';
|
||||
import { en } from './i18n/Translations';
|
||||
@@ -21,7 +21,7 @@ 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 { base64ToBlob } from 'file64';
|
||||
import { ImagePickerAsset } from 'expo-image-picker';
|
||||
|
||||
type AuthenticationInput = { username: string; password: string };
|
||||
@@ -68,7 +68,7 @@ export default class API {
|
||||
public static readonly baseUrl =
|
||||
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
|
||||
? '/api'
|
||||
: 'https://nightly.chroma.octohub.app/api';
|
||||
: Constants.manifest?.extra?.apiUrl;
|
||||
public static async fetch(
|
||||
params: FetchParams,
|
||||
handle: Pick<Required<HandleParams>, 'raw'>
|
||||
@@ -97,16 +97,8 @@ export default class API {
|
||||
});
|
||||
if (!handle || handle.emptyResponse) {
|
||||
if (!response.ok) {
|
||||
let responseMessage = response.statusText;
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
console.log(responseData);
|
||||
if (responseData.message) responseMessage = responseData.message;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new APIError(response.statusText, response.status, 'unknownError');
|
||||
}
|
||||
throw new APIError(responseMessage, response.status, 'unknownError');
|
||||
console.log(await response.json());
|
||||
throw new APIError(response.statusText, response.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +110,7 @@ export default class API {
|
||||
try {
|
||||
const jsonResponse = JSON.parse(body);
|
||||
if (!response.ok) {
|
||||
throw new APIError(response.statusText ?? body, response.status, 'unknownError');
|
||||
throw new APIError(response.statusText ?? body, response.status);
|
||||
}
|
||||
const validated = await handler.validator.validate(jsonResponse).catch((e) => {
|
||||
if (e instanceof yup.ValidationError) {
|
||||
@@ -295,43 +287,6 @@ export default class API {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description retrieves songs from a specific artist
|
||||
* @param artistId is the id of the artist that composed the songs aimed
|
||||
* @returns a Promise of Songs type array
|
||||
*/
|
||||
public static getSongsByArtist(artistId: number): Query<Song[]> {
|
||||
return {
|
||||
key: ['artist', artistId, 'songs'],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/song?artistId=${artistId}`,
|
||||
},
|
||||
{ handler: PlageHandler(SongHandler) }
|
||||
).then(({ data }) => data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -367,23 +322,6 @@ export default class API {
|
||||
return `${API.baseUrl}/genre/${genreId}/illustration`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param songId the id to find the song
|
||||
@@ -670,31 +608,4 @@ export default class API {
|
||||
formData,
|
||||
});
|
||||
}
|
||||
|
||||
public static async addLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
public static async removeLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
public static getLikedSongs(): Query<likedSong[]> {
|
||||
return {
|
||||
key: ['liked songs'],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: '/auth/me/likes',
|
||||
},
|
||||
{ handler: ListHandler(LikedSongHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import store, { persistor } from './state/Store';
|
||||
@@ -10,22 +10,12 @@ import LanguageGate from './i18n/LanguageGate';
|
||||
import ThemeProvider, { ColorSchemeProvider } from './Theme';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { QueryRules } from './Queries';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
const queryClient = new QueryClient(QueryRules);
|
||||
|
||||
export default function App() {
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
Lexend: require('./assets/fonts/lexend.ttf'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fontsLoaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [fontsLoaded]);
|
||||
setTimeout(SplashScreen.hideAsync, 500);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
|
||||
@@ -22,5 +22,6 @@ RUN yarn tsc && expo build:web
|
||||
# Serve the app
|
||||
FROM nginx:1.21-alpine
|
||||
COPY --from=build /app/web-build /usr/share/nginx/html
|
||||
COPY ./assets/ /usr/share/nginx/html/assets/
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY nginx.conf.template /etc/nginx/conf.d/default.conf.template
|
||||
|
||||
CMD envsubst '$API_URL $SCOROMETER_URL $NGINX_PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RootState, useSelector } from './state/Store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Translate, translate } from './i18n/i18n';
|
||||
import SongLobbyView from './views/SongLobbyView';
|
||||
import AuthenticationView from './views/AuthenticationView';
|
||||
import StartPageView from './views/StartPageView';
|
||||
import HomeView from './views/HomeView';
|
||||
import SearchView from './views/SearchView';
|
||||
@@ -27,14 +28,8 @@ import { Button, Center, VStack } from 'native-base';
|
||||
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';
|
||||
import SigninView from './views/SigninView';
|
||||
import SignupView from './views/SignupView';
|
||||
import TabNavigation from './components/V2/TabNavigation';
|
||||
import PasswordResetView from './views/PasswordResetView';
|
||||
import ForgotPasswordView from './views/ForgotPasswordView';
|
||||
|
||||
// Util function to hide route props in URL
|
||||
const removeMe = () => '';
|
||||
@@ -46,11 +41,6 @@ const protectedRoutes = () =>
|
||||
options: { title: translate('welcome'), headerLeft: null },
|
||||
link: '/',
|
||||
},
|
||||
HomeNew: {
|
||||
component: TabNavigation,
|
||||
options: { headerShown: false },
|
||||
link: '/V2',
|
||||
},
|
||||
Play: { component: PlayView, options: { title: translate('play') }, link: '/play/:songId' },
|
||||
Settings: {
|
||||
component: SetttingsNavigator,
|
||||
@@ -70,11 +60,6 @@ const protectedRoutes = () =>
|
||||
options: { title: translate('artistFilter') },
|
||||
link: '/artist/:artistId',
|
||||
},
|
||||
Genre: {
|
||||
component: GenreDetailsView,
|
||||
options: { title: translate('genreFilter') },
|
||||
link: '/genre/:genreId',
|
||||
},
|
||||
Score: {
|
||||
component: ScoreView,
|
||||
options: { title: translate('score'), headerLeft: null },
|
||||
@@ -106,13 +91,15 @@ const publicRoutes = () =>
|
||||
link: '/',
|
||||
},
|
||||
Login: {
|
||||
component: SigninView,
|
||||
options: { title: translate('signInBtn'), headerShown: false },
|
||||
component: (params: RouteProps<{}>) =>
|
||||
AuthenticationView({ isSignup: false, ...params }),
|
||||
options: { title: translate('signInBtn') },
|
||||
link: '/login',
|
||||
},
|
||||
Signup: {
|
||||
component: SignupView,
|
||||
options: { title: translate('signUpBtn'), headerShown: false },
|
||||
component: (params: RouteProps<{}>) =>
|
||||
AuthenticationView({ isSignup: true, ...params }),
|
||||
options: { title: translate('signUpBtn') },
|
||||
link: '/signup',
|
||||
},
|
||||
Oops: {
|
||||
@@ -125,16 +112,6 @@ const publicRoutes = () =>
|
||||
options: { title: 'Google signin', headerShown: false },
|
||||
link: '/logged/google',
|
||||
},
|
||||
PasswordReset: {
|
||||
component: PasswordResetView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/password_reset',
|
||||
},
|
||||
ForgotPassword: {
|
||||
component: ForgotPasswordView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/forgot_password',
|
||||
},
|
||||
} as const);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
143
front/Theme.tsx
@@ -12,118 +12,63 @@ const ThemeProvider = ({ children }: { children: JSX.Element }) => {
|
||||
useSystemColorMode: false,
|
||||
initialColorMode: colorScheme,
|
||||
},
|
||||
fonts: {
|
||||
heading: 'Lexend',
|
||||
body: 'Lexend',
|
||||
mono: 'Lexend',
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff1fe',
|
||||
100: '#e7eafe',
|
||||
200: '#cdd4fd',
|
||||
300: '#5f74f7',
|
||||
400: '#5668de',
|
||||
500: '#4c5dc6',
|
||||
600: '#4757b9',
|
||||
700: '#394694',
|
||||
800: '#2b346f',
|
||||
900: '#212956',
|
||||
50: '#e6faea',
|
||||
100: '#c8e7d0',
|
||||
200: '#a7d6b5',
|
||||
300: '#86c498',
|
||||
400: '#65b47c',
|
||||
500: '#4b9a62',
|
||||
600: '#3a784b',
|
||||
700: '#275635',
|
||||
800: '#14341f',
|
||||
900: '#001405',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f7f3ff',
|
||||
100: '#f3edfe',
|
||||
200: '#e6d9fe',
|
||||
300: '#ae84fb',
|
||||
400: '#9d77e2',
|
||||
500: '#8b6ac9',
|
||||
600: '#8363bc',
|
||||
700: '#684f97',
|
||||
800: '#4e3b71',
|
||||
900: '#3d2e58',
|
||||
50: '#d8ffff',
|
||||
100: '#acffff',
|
||||
200: '#7dffff',
|
||||
300: '#4dffff',
|
||||
400: '#28ffff',
|
||||
500: '#18e5e6',
|
||||
600: '#00b2b3',
|
||||
700: '#007f80',
|
||||
800: '#004d4e',
|
||||
900: '#001b1d',
|
||||
},
|
||||
error: {
|
||||
50: '#f7f3ff',
|
||||
100: '#f3edfe',
|
||||
200: '#e6d9fe',
|
||||
300: '#ae84fb',
|
||||
400: '#9d77e2',
|
||||
500: '#8b6ac9',
|
||||
600: '#8363bc',
|
||||
700: '#684f97',
|
||||
800: '#4e3b71',
|
||||
900: '#3d2e58',
|
||||
},
|
||||
alert: {
|
||||
50: '#fff2f1',
|
||||
100: '#ffebea',
|
||||
200: '#ffd6d3',
|
||||
300: '#ff7a72',
|
||||
400: '#e66e67',
|
||||
500: '#cc625b',
|
||||
600: '#bf5c56',
|
||||
700: '#994944',
|
||||
800: '#733733',
|
||||
900: '#592b28',
|
||||
50: '#ffe2e9',
|
||||
100: '#ffb1bf',
|
||||
200: '#ff7f97',
|
||||
300: '#ff4d6d',
|
||||
400: '#fe1d43',
|
||||
500: '#e5062b',
|
||||
600: '#b30020',
|
||||
700: '#810017',
|
||||
800: '#4f000c',
|
||||
900: '#200004',
|
||||
},
|
||||
notification: {
|
||||
50: '#fdfbec',
|
||||
100: '#fcf9e2',
|
||||
200: '#f8f3c3',
|
||||
300: '#ead93c',
|
||||
400: '#d3c336',
|
||||
500: '#bbae30',
|
||||
600: '#b0a32d',
|
||||
700: '#8c8224',
|
||||
800: '#69621b',
|
||||
900: '#524c15',
|
||||
},
|
||||
black: {
|
||||
50: '#e7e7e8',
|
||||
100: '#dbdbdc',
|
||||
200: '#b5b5b6',
|
||||
300: '#101014',
|
||||
400: '#0e0e12',
|
||||
500: '#0d0d10',
|
||||
600: '#0c0c0f',
|
||||
700: '#0a0a0c',
|
||||
800: '#070709',
|
||||
900: '#060607',
|
||||
},
|
||||
red: {
|
||||
50: '#fdedee',
|
||||
100: '#fce4e5',
|
||||
200: '#f9c7c9',
|
||||
300: '#ed4a51',
|
||||
400: '#d54349',
|
||||
500: '#be3b41',
|
||||
600: '#b2383d',
|
||||
700: '#8e2c31',
|
||||
800: '#6b2124',
|
||||
900: '#531a1c',
|
||||
50: '#ffe1e1',
|
||||
100: '#ffb1b1',
|
||||
200: '#ff7f7f',
|
||||
300: '#ff4c4c',
|
||||
400: '#ff1a1a',
|
||||
500: '#e60000',
|
||||
600: '#b40000',
|
||||
700: '#810000',
|
||||
800: '#500000',
|
||||
900: '#210000',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
baseStyle: () => ({
|
||||
borderRadius: 'md',
|
||||
}),
|
||||
},
|
||||
Link: {
|
||||
defaultProps: {
|
||||
isUnderlined: false,
|
||||
variants: {
|
||||
solid: () => ({
|
||||
rounded: 'full',
|
||||
}),
|
||||
},
|
||||
baseStyle: () => ({
|
||||
_text: {
|
||||
color: 'secondary.300',
|
||||
},
|
||||
_hover: {
|
||||
isUnderlined: true,
|
||||
_text: {
|
||||
color: 'secondary.400',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'light',
|
||||
splash: {
|
||||
image: './assets/splash.png',
|
||||
image: './assets/splashLogo.png',
|
||||
resizeMode: 'contain',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
@@ -18,6 +18,12 @@ module.exports = {
|
||||
supportsTablet: true,
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#FFFFFF',
|
||||
package: 'com.chromacase.chromacase',
|
||||
versionCode: 1,
|
||||
},
|
||||
package: 'build.apk',
|
||||
},
|
||||
web: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"image": "./assets/splashLogo.png",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
@@ -19,6 +19,10 @@
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
},
|
||||
"package": "build.apk"
|
||||
},
|
||||
"web": {
|
||||
|
||||
BIN
front/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 609 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
front/assets/cover.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 631 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 46 KiB |
BIN
front/assets/splashLogo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 498 KiB |
@@ -16,7 +16,7 @@ import useColorScheme from '../hooks/colorScheme';
|
||||
type BigActionButtonProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image?: string;
|
||||
image: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
iconName?: string;
|
||||
// It is not possible to recover the type, the `Icon` parameter is `any` as well.
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { HStack, IconButton, Image, Text } from 'native-base';
|
||||
import RowCustom from './RowCustom';
|
||||
import TextButton from './TextButton';
|
||||
import { LikedSongWithDetails } from '../models/LikedSong';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import API from '../API';
|
||||
|
||||
type FavSongRowProps = {
|
||||
FavSong: LikedSongWithDetails; // TODO: remove Song
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const FavSongRow = ({ FavSong, onPress }: FavSongRowProps) => {
|
||||
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: FavSong.details.cover }}
|
||||
alt={FavSong.details.name}
|
||||
borderColor={'white'}
|
||||
borderWidth={1}
|
||||
/>
|
||||
<HStack
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
space={6}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flexShrink: 1,
|
||||
}}
|
||||
isTruncated
|
||||
pl={5}
|
||||
maxW={'100%'}
|
||||
bold
|
||||
fontSize="md"
|
||||
>
|
||||
{FavSong.details.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
{FavSong.addedDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
colorScheme="primary"
|
||||
variant={'ghost'}
|
||||
borderRadius={'full'}
|
||||
onPress={() => {
|
||||
API.removeLikedSong(FavSong.songId);
|
||||
}}
|
||||
_icon={{
|
||||
as: MaterialIcons,
|
||||
name: 'favorite',
|
||||
}}
|
||||
/>
|
||||
<TextButton
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
translate={{ translationKey: 'playBtn' }}
|
||||
colorScheme="primary"
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
mr={5}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</HStack>
|
||||
</RowCustom>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavSongRow;
|
||||
@@ -1,13 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { ElementProps } from './ElementTypes';
|
||||
import { RawElement } from './RawElement';
|
||||
import { View, Column } from 'native-base';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import InteractiveBase from '../UI/InteractiveBase';
|
||||
import { Pressable, IPressableProps } from 'native-base';
|
||||
|
||||
export const Element = <T extends ElementProps>(props: T) => {
|
||||
let actionFunction: (() => void) | null | undefined = null;
|
||||
const [dropdownValue, setDropdownValue] = useState(false);
|
||||
let actionFunction: IPressableProps['onPress'] = null;
|
||||
|
||||
switch (props.type) {
|
||||
case 'text':
|
||||
@@ -16,81 +13,18 @@ export const Element = <T extends ElementProps>(props: T) => {
|
||||
case 'toggle':
|
||||
actionFunction = props.data?.onToggle;
|
||||
break;
|
||||
case 'sectionDropdown':
|
||||
actionFunction = () => {
|
||||
props.data.value = !props.data.value;
|
||||
setDropdownValue(!dropdownValue);
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const styleSetting = StyleSheet.create({
|
||||
Default: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: 'rgba(16, 16, 20, 0.50)',
|
||||
},
|
||||
onHover: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: 'rgba(32, 32, 40, 0.50)',
|
||||
},
|
||||
onPressed: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: 'rgba(16, 16, 20, 0.50)',
|
||||
},
|
||||
Disabled: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: 'rgba(16, 16, 20, 0.50)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!props?.disabled && actionFunction) {
|
||||
return (
|
||||
<Column>
|
||||
<InteractiveBase
|
||||
style={{ width: '100%' }}
|
||||
styleAnimate={styleSetting}
|
||||
onPress={async () => {
|
||||
actionFunction?.();
|
||||
}}
|
||||
>
|
||||
<RawElement element={props} />
|
||||
</InteractiveBase>
|
||||
{props.type === 'sectionDropdown' && dropdownValue && (
|
||||
<View backgroundColor={'rgba(16,16,20,0.3)'}>
|
||||
{props.data.section.map((value, index) => (
|
||||
<View
|
||||
key={value?.toString() + index.toString()}
|
||||
style={{
|
||||
padding: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Column>
|
||||
<Pressable onPress={actionFunction}>
|
||||
{({ isHovered }) => {
|
||||
return <RawElement element={props} isHovered={isHovered} />;
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={{ backgroundColor: 'rgba(16, 16, 20, 0.50)' }}>
|
||||
<RawElement element={props} />
|
||||
</View>
|
||||
);
|
||||
return <RawElement element={props} />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
import { Element } from './Element';
|
||||
import useColorScheme from '../../hooks/colorScheme';
|
||||
import { ElementProps } from './ElementTypes';
|
||||
|
||||
import { Box, Column, Divider } from 'native-base';
|
||||
|
||||
type ElementListProps = {
|
||||
@@ -10,12 +12,13 @@ type ElementListProps = {
|
||||
};
|
||||
|
||||
const ElementList = ({ elements, style }: ElementListProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const elementStyle = {
|
||||
borderRadius: 10,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: isDark
|
||||
? '0px 0px 3px 0px rgba(255,255,255,0.6)'
|
||||
: '0px 0px 3px 0px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
} as const;
|
||||
|
||||
@@ -24,7 +27,7 @@ const ElementList = ({ elements, style }: ElementListProps) => {
|
||||
{elements.map((element, index) => (
|
||||
<Box key={element.title}>
|
||||
<Element {...element} />
|
||||
{index < elements.length - 1 && <Divider bg="transparent" thickness="2" />}
|
||||
{index < elements.length - 1 && <Divider />}
|
||||
</Box>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Select, Switch, Text, Icon, Row, Slider } from 'native-base';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
export type ElementProps = {
|
||||
title: string;
|
||||
@@ -13,7 +12,6 @@ export type ElementProps = {
|
||||
| { type: 'toggle'; data: ElementToggleProps }
|
||||
| { type: 'dropdown'; data: ElementDropdownProps }
|
||||
| { type: 'range'; data: ElementRangeProps }
|
||||
| { type: 'sectionDropdown'; data: SectionDropdownProps }
|
||||
| { type: 'custom'; data: React.ReactNode }
|
||||
);
|
||||
|
||||
@@ -33,11 +31,6 @@ export type ElementToggleProps = {
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type SectionDropdownProps = {
|
||||
value: boolean;
|
||||
section: React.ReactNode[];
|
||||
};
|
||||
|
||||
export type ElementDropdownProps = {
|
||||
options: DropdownOption[];
|
||||
onSelect: (value: string) => void;
|
||||
@@ -100,16 +93,13 @@ export const getElementDropdownNode = (
|
||||
{ options, onSelect, value, defaultValue }: ElementDropdownProps,
|
||||
disabled: boolean
|
||||
) => {
|
||||
const layout = useWindowDimensions();
|
||||
return (
|
||||
<Select
|
||||
selectedValue={value}
|
||||
onValueChange={onSelect}
|
||||
defaultValue={defaultValue}
|
||||
bgColor={'rgba(16,16,20,0.5)'}
|
||||
variant="filled"
|
||||
isDisabled={disabled}
|
||||
width={layout.width > 650 ? '200' : '100'}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Select.Item key={option.label} label={option.label} value={option.value} />
|
||||
@@ -123,7 +113,6 @@ export const getElementRangeNode = (
|
||||
disabled: boolean,
|
||||
title: string
|
||||
) => {
|
||||
const layout = useWindowDimensions();
|
||||
return (
|
||||
<Slider
|
||||
// this is a hot fix for now but ideally this input should be managed
|
||||
@@ -137,7 +126,7 @@ export const getElementRangeNode = (
|
||||
isDisabled={disabled}
|
||||
onChangeEnd={onChange}
|
||||
accessibilityLabel={`Slider for ${title}`}
|
||||
width={layout.width > 650 ? '200' : '100'}
|
||||
width="200"
|
||||
>
|
||||
<Slider.Track>
|
||||
<Slider.FilledTrack />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Column, Icon, Popover, Row, Text, useBreakpointValue } from 'native-base';
|
||||
import useColorScheme from '../../hooks/colorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { ElementProps } from './ElementTypes';
|
||||
import {
|
||||
@@ -8,124 +9,118 @@ import {
|
||||
getElementToggleNode,
|
||||
getElementRangeNode,
|
||||
} from './ElementTypes';
|
||||
import { ArrowDown2 } from 'iconsax-react-native';
|
||||
|
||||
type RawElementProps = {
|
||||
element: ElementProps;
|
||||
isHovered?: boolean;
|
||||
};
|
||||
|
||||
export const RawElement = ({ element }: RawElementProps) => {
|
||||
export const RawElement = ({ element, isHovered }: RawElementProps) => {
|
||||
const { title, icon, type, helperText, description, disabled, data } = element;
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
|
||||
const isSmallScreen = screenSize === 'small';
|
||||
return (
|
||||
<Column
|
||||
<Row
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
height: 45,
|
||||
padding: 15,
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'stretch',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isHovered
|
||||
? isDark
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.05)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 45,
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'stretch',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Box
|
||||
<Column maxW={'90%'}>
|
||||
<Text isTruncated maxW={'100%'}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
isTruncated
|
||||
maxW={'100%'}
|
||||
style={{
|
||||
opacity: 0.6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
paddingLeft: icon ? 16 : 0,
|
||||
alignItems: 'center',
|
||||
marginRight: 3,
|
||||
}}
|
||||
>
|
||||
<Column maxW={'90%'}>
|
||||
<Text isTruncated maxW={'100%'}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
isTruncated
|
||||
maxW={'100%'}
|
||||
{helperText && (
|
||||
<Popover
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
color="gray.500"
|
||||
leftIcon={
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
size={'md'}
|
||||
name="help-circle-outline"
|
||||
/>
|
||||
}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Popover.Content
|
||||
accessibilityLabel={`Additionnal information for ${title}`}
|
||||
style={{
|
||||
opacity: 0.6,
|
||||
fontSize: 10,
|
||||
maxWidth: isSmallScreen ? '90vw' : '20vw',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
marginRight: 3,
|
||||
}}
|
||||
>
|
||||
{helperText && (
|
||||
<Popover
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
color="gray.500"
|
||||
leftIcon={
|
||||
<Icon
|
||||
as={Ionicons}
|
||||
size={'md'}
|
||||
name="help-circle-outline"
|
||||
/>
|
||||
}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Popover.Content
|
||||
accessibilityLabel={`Additionnal information for ${title}`}
|
||||
style={{
|
||||
maxWidth: isSmallScreen ? '90vw' : '20vw',
|
||||
}}
|
||||
>
|
||||
<Popover.Arrow />
|
||||
<Popover.Body>{helperText}</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
{(() => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return getElementTextNode(data, disabled ?? false);
|
||||
case 'toggle':
|
||||
return getElementToggleNode(data, disabled ?? false);
|
||||
case 'dropdown':
|
||||
return getElementDropdownNode(data, disabled ?? false);
|
||||
case 'range':
|
||||
return getElementRangeNode(data, disabled ?? false, title);
|
||||
case 'custom':
|
||||
return data;
|
||||
case 'sectionDropdown':
|
||||
return <ArrowDown2 size="24" color="#fff" variant="Outline" />;
|
||||
default:
|
||||
return <Text>Unknown type</Text>;
|
||||
}
|
||||
})()}
|
||||
</Row>
|
||||
</Box>
|
||||
</Row>
|
||||
</Column>
|
||||
<Popover.Arrow />
|
||||
<Popover.Body>{helperText}</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
{(() => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return getElementTextNode(data, disabled ?? false);
|
||||
case 'toggle':
|
||||
return getElementToggleNode(data, disabled ?? false);
|
||||
case 'dropdown':
|
||||
return getElementDropdownNode(data, disabled ?? false);
|
||||
case 'range':
|
||||
return getElementRangeNode(data, disabled ?? false, title);
|
||||
case 'custom':
|
||||
return data;
|
||||
default:
|
||||
return <Text>Unknown type</Text>;
|
||||
}
|
||||
})()}
|
||||
</Row>
|
||||
</Box>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Slider, Switch, Text, View } from 'native-base';
|
||||
|
||||
export const Metronome = ({ paused = false, bpm }: { paused?: boolean; bpm: number }) => {
|
||||
const ref = useRef<HTMLAudioElement | null>(null);
|
||||
const enabled = useRef<boolean>(false);
|
||||
const volume = useRef<number>(50);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const int = setInterval(() => {
|
||||
if (!enabled.current) return;
|
||||
if (!ref.current) ref.current = new Audio('/assets/metronome.mp3');
|
||||
ref.current.volume = volume.current / 100;
|
||||
ref.current.play();
|
||||
}, 60000 / bpm);
|
||||
return () => clearInterval(int);
|
||||
}, [bpm, paused]);
|
||||
return (
|
||||
<View>
|
||||
<Text>Metronome Settings</Text>
|
||||
<Text>Enabled:</Text>
|
||||
<Switch value={enabled.current} onToggle={() => (enabled.current = !enabled.current)} />
|
||||
<Text>Volume:</Text>
|
||||
<Slider
|
||||
maxWidth={'500px'}
|
||||
value={volume.current}
|
||||
onChange={(x) => (volume.current = x)}
|
||||
>
|
||||
<Slider.Track>
|
||||
<Slider.FilledTrack />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb />
|
||||
</Slider>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import PartitionView from './PartitionView';
|
||||
import PhaserCanvas from './PartitionVisualizer/PhaserCanvas';
|
||||
import { PianoCursorPosition } from '../models/PianoGame';
|
||||
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
|
||||
|
||||
type PartitionCoordProps = {
|
||||
// The Buffer of the MusicXML file retreived from the API
|
||||
file: string;
|
||||
bpmRef: React.MutableRefObject<number>;
|
||||
onPartitionReady: () => void;
|
||||
onEndReached: () => void;
|
||||
onResume: () => void;
|
||||
onPause: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PartitionCoord = ({
|
||||
@@ -19,10 +20,10 @@ const PartitionCoord = ({
|
||||
onEndReached,
|
||||
onPause,
|
||||
onResume,
|
||||
bpmRef,
|
||||
timestamp,
|
||||
}: PartitionCoordProps) => {
|
||||
const [partitionData, setPartitionData] = React.useState<
|
||||
[[number, number], string, PianoCursorPosition[]] | null
|
||||
[string, PianoCursorPosition[]] | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
@@ -30,22 +31,21 @@ const PartitionCoord = ({
|
||||
{!partitionData && (
|
||||
<PartitionView
|
||||
file={file}
|
||||
bpmRef={bpmRef}
|
||||
onPartitionReady={(dims, base64data, a) => {
|
||||
setPartitionData([dims, base64data, a]);
|
||||
onPartitionReady={(base64data, a) => {
|
||||
setPartitionData([base64data, a]);
|
||||
onPartitionReady();
|
||||
}}
|
||||
onEndReached={() => {
|
||||
console.log('osmd end reached');
|
||||
}}
|
||||
timestamp={0}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
{partitionData && (
|
||||
<PhaserCanvas
|
||||
partitionDims={partitionData?.[0]}
|
||||
partitionB64={partitionData?.[1]}
|
||||
cursorPositions={partitionData?.[2]}
|
||||
partitionB64={partitionData?.[0]}
|
||||
cursorPositions={partitionData?.[1]}
|
||||
timestamp={timestamp}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onEndReached={() => {
|
||||
|
||||
@@ -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, { MutableRefObject, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
CursorType,
|
||||
Fraction,
|
||||
@@ -10,16 +10,12 @@ import {
|
||||
Note,
|
||||
} from 'opensheetmusicdisplay';
|
||||
import useColorScheme from '../hooks/colorScheme';
|
||||
import { PianoCursorPosition } from '../models/PianoGame';
|
||||
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
|
||||
|
||||
type PartitionViewProps = {
|
||||
// The Buffer of the MusicXML file retreived from the API
|
||||
file: string;
|
||||
onPartitionReady: (
|
||||
dims: [number, number],
|
||||
base64data: string,
|
||||
cursorInfos: PianoCursorPosition[]
|
||||
) => void;
|
||||
bpmRef: MutableRefObject<number>;
|
||||
onPartitionReady: (base64data: string, cursorInfos: PianoCursorPosition[]) => void;
|
||||
onEndReached: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
@@ -63,7 +59,6 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
_osmd.render();
|
||||
_osmd.cursor.show();
|
||||
const bpm = _osmd.Sheet.HasBPMInfo ? _osmd.Sheet.getExpressionsStartTempoInBPM() : 60;
|
||||
props.bpmRef.current = bpm;
|
||||
const wholeNoteLength = Math.round((60 / bpm) * 4000);
|
||||
const curPos = [];
|
||||
while (!_osmd.cursor.iterator.EndReached) {
|
||||
@@ -102,6 +97,7 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
});
|
||||
_osmd.cursor.next();
|
||||
}
|
||||
// console.log('curPos', curPos);
|
||||
_osmd.cursor.reset();
|
||||
_osmd.cursor.hide();
|
||||
// console.log('timestamp cursor', _osmd.cursor.iterator.CurrentSourceTimestamp);
|
||||
@@ -114,18 +110,12 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
if (!osmdCanvas) {
|
||||
throw new Error('No canvas found');
|
||||
}
|
||||
let scale = osmdCanvas.width / parseFloat(osmdCanvas.style.width);
|
||||
if (Number.isNaN(scale)) {
|
||||
console.error('Scale is NaN setting it to 1');
|
||||
scale = 1;
|
||||
}
|
||||
// Ty https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL77C12-L77C63
|
||||
props.onPartitionReady(
|
||||
[osmdCanvas.width, osmdCanvas.height],
|
||||
osmdCanvas.toDataURL(),
|
||||
curPos.map((pos) => {
|
||||
return {
|
||||
x: pos.offset * scale,
|
||||
x: pos.offset,
|
||||
timing: pos.sNinfos.sNL,
|
||||
timestamp: pos.sNinfos.ts,
|
||||
notes: pos.notes,
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
// create a simple phaser effect with a canvas that can be easily imported as a react component
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useContext } from 'react';
|
||||
import { Dimensions } from 'react-native';
|
||||
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 { handlePianoGameMsg } from './PianoGameUpdateFunctions';
|
||||
import { PianoCC } from '../../views/PlayView';
|
||||
import { PianoCanvasMsg, PianoCursorNote, PianoCursorPosition } from '../../models/PianoGame';
|
||||
import { Note } from 'opensheetmusicdisplay';
|
||||
|
||||
let globalTimestamp = 0;
|
||||
let globalPressedKeys: Map<number, number> = new Map();
|
||||
// the messages are consummed from the end and new messages should be added at the end
|
||||
let globalMessages: Array<PianoCanvasMsg> = [];
|
||||
const globalStatus: 'playing' | 'paused' | 'stopped' = 'playing';
|
||||
|
||||
const isValidSoundPlayer = (soundPlayer: SplendidGrandPiano | undefined) => {
|
||||
return soundPlayer && soundPlayer.loaded;
|
||||
};
|
||||
|
||||
const min = (a: number, b: number) => (a < b ? a : b);
|
||||
const max = (a: number, b: number) => (a > b ? a : b);
|
||||
|
||||
const myFindLast = <T,>(a: T[], p: (_: T, _2: number) => boolean) => {
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
if (p(a[i]!, i)) {
|
||||
@@ -57,42 +48,17 @@ const getPianoScene = (
|
||||
private cursorPositionsIdx = -1;
|
||||
private partition!: Phaser.GameObjects.Image;
|
||||
private cursor!: Phaser.GameObjects.Rectangle;
|
||||
private emitter!: Phaser.GameObjects.Particles.ParticleEmitter;
|
||||
private nbTextureToload!: number;
|
||||
create() {
|
||||
this.textures.addBase64(
|
||||
'star',
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEQAAAApCAYAAACMeY82AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDE0IDc5LjE1Njc5NywgMjAxNC8wOC8yMC0wOTo1MzowMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTQgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjYyNzNEOEU4NzUxMzExRTRCN0ZCODQ1QUJCREFFQzA4IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjYyNzNEOEU5NzUxMzExRTRCN0ZCODQ1QUJCREFFQzA4Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NjI3M0Q4RTY3NTEzMTFFNEI3RkI4NDVBQkJEQUVDMDgiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NjI3M0Q4RTc3NTEzMTFFNEI3RkI4NDVBQkJEQUVDMDgiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4vDiTDAAAB4UlEQVR42uxagY2DMAw0VRdghayQFVghs3SF/1XaEdoVGIGswAj5OK0rkwfalx5wiY2smAik+OrYFxcAAWLABFQJazmAykCOEhZRx0sBYdLHS0VFk6omVU2qOwRktS1jwYY5QKSAslqEtNBWM0lVt4xUQERUmdrWSV+VZi27xVYZU1OimYyOmJTRDB58VVyE5BUJQYhJGZYGYzMH87nuqwuoRSRVdLyB5hcoWIZxjs9P2buT3LkI0PP+BKcQriEp2jjnwO0XjDFwUDFRouNXF5HoQlK0iwKDVw10/GzPdzBIoo1zBApF1prb57iUw/zQxkdkpY1rwNhoOQMDkhpt62wqw+ZisMTiOwNQqLu2VMWpxhggOcBbe/zwlTvKqTfZxDyJYyAAfEyPTTF2/1AcWj8Ye39fU98+geHlebBuvv4xX8Zal5WoCEGnvn1y/na5JQdz55aOkM3knRyS5xwpbcZ/BSG//2uVWTrB6uFOAjHjoT9GzIojZzlYdJaRQNcPW0cMby3OtRl32w950PbU2yAAiGPk22qL0rp4hOSlEkFAfvGi6RwenCXsLkLGfuUcDGKf/J2tIkTGv/9t/xaQxQDCzyPFJdU1AYns5kO3jKAPZhQQPctohHweIJK+D/kRYAAaWClvtE6otAAAAABJRU5ErkJggg=='
|
||||
);
|
||||
this.textures.addBase64('partition', partitionB64);
|
||||
this.cursorPositionsIdx = -1;
|
||||
// this is to prevent multiple initialisation of the scene
|
||||
this.nbTextureToload = 2;
|
||||
|
||||
this.cameras.main.setBackgroundColor(colorScheme === 'light' ? '#FFFFFF' : '#000000');
|
||||
this.textures.on('onload', () => {
|
||||
this.nbTextureToload--;
|
||||
if (this.nbTextureToload > 0) return;
|
||||
this.partition = this.add.image(0, 0, 'partition').setOrigin(0, 0);
|
||||
this.cameras.main.setBounds(0, 0, this.partition.width, this.partition.height);
|
||||
|
||||
const dims = this.partition.getBounds();
|
||||
// base ref normal cursor is 276px by 30px
|
||||
this.cursor = this.add
|
||||
.rectangle(0, 0, (dims.height * 30) / 276, dims.height, 0x31ef8c, 0.5)
|
||||
.setOrigin(0, 0);
|
||||
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);
|
||||
|
||||
this.emitter = this.add.particles(0, 0, 'star', {
|
||||
lifespan: 700,
|
||||
duration: 100,
|
||||
follow: this.cursor,
|
||||
speed: { min: 10, max: 20 },
|
||||
scale: { start: 0, end: 0.4 },
|
||||
emitZone: { type: 'edge', source: this.cursor.getBounds(), quantity: 50 },
|
||||
|
||||
emitting: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,16 +76,6 @@ const getPianoScene = (
|
||||
this.cursorPositionsIdx = idx;
|
||||
return true;
|
||||
}
|
||||
if (globalPressedKeys.size > 0) {
|
||||
this.cursor.fillAlpha = 0.9;
|
||||
} else if (this.cursor) {
|
||||
this.cursor.fillAlpha = 0.5;
|
||||
}
|
||||
|
||||
if (globalMessages.length > 0) {
|
||||
handlePianoGameMsg(globalMessages, this.emitter, undefined);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
if (cP) {
|
||||
@@ -144,30 +100,47 @@ const getPianoScene = (
|
||||
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 = {
|
||||
partitionDims: [number, number];
|
||||
partitionB64: string;
|
||||
cursorPositions: PianoCursorPosition[];
|
||||
onEndReached: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PhaserCanvas = ({
|
||||
partitionDims,
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
timestamp,
|
||||
}: PhaserCanvasProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const dispatch = useDispatch();
|
||||
const pianoCC = useContext(PianoCC);
|
||||
const soundPlayer = useSelector((state: RootState) => state.soundPlayer.soundPlayer);
|
||||
const [game, setGame] = React.useState<Phaser.Game | null>(null);
|
||||
|
||||
globalTimestamp = pianoCC.timestamp;
|
||||
globalPressedKeys = pianoCC.pressedKeys;
|
||||
globalMessages = pianoCC.messages;
|
||||
globalTimestamp = timestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidSoundPlayer(soundPlayer)) {
|
||||
@@ -191,43 +164,13 @@ const PhaserCanvas = ({
|
||||
soundPlayer,
|
||||
colorScheme
|
||||
);
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
class UIScene extends Phaser.Scene {
|
||||
private statusTextValue: string;
|
||||
private statusText!: Phaser.GameObjects.Text;
|
||||
constructor() {
|
||||
super({ key: 'UIScene', active: true });
|
||||
|
||||
this.statusTextValue = 'Score: 0 Streak: 0';
|
||||
}
|
||||
|
||||
create() {
|
||||
this.statusText = this.add.text(
|
||||
this.cameras.main.width - 300,
|
||||
10,
|
||||
this.statusTextValue,
|
||||
{
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 25,
|
||||
color: '#3A784B',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override update() {
|
||||
if (globalMessages.length > 0) {
|
||||
handlePianoGameMsg(globalMessages, undefined, this.statusText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'phaser-canvas',
|
||||
width: max(width * 0.9, 850),
|
||||
height: min(max(height * 0.7, 400), partitionDims[1]),
|
||||
scene: [pianoScene, UIScene],
|
||||
width: 1000,
|
||||
height: 400,
|
||||
scene: pianoScene,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_HORIZONTALLY,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NoteTiming, PianoCanvasMsg, PianoScoreInfo } from '../../models/PianoGame';
|
||||
|
||||
const handleNoteTimingMsg = (
|
||||
noteTiming: NoteTiming,
|
||||
emitter: Phaser.GameObjects.Particles.ParticleEmitter
|
||||
) => {
|
||||
if (noteTiming === NoteTiming.Perfect) {
|
||||
// gold
|
||||
emitter.particleTint = 0xffd700;
|
||||
emitter.start(20);
|
||||
} else if (noteTiming === NoteTiming.Great) {
|
||||
emitter.particleTint = 0x00ffff;
|
||||
emitter.start(10);
|
||||
} else if (noteTiming === NoteTiming.Good) {
|
||||
// orange/brown
|
||||
emitter.particleTint = 0xffa500;
|
||||
emitter.start(5);
|
||||
} else if (noteTiming === NoteTiming.Missed) {
|
||||
emitter.particleTint = 0xff0000;
|
||||
emitter.start(5);
|
||||
} else if (noteTiming === NoteTiming.Wrong) {
|
||||
// maybe add some other effect
|
||||
}
|
||||
};
|
||||
|
||||
const handleScoreMsg = (score: PianoScoreInfo, statusText: Phaser.GameObjects.Text) => {
|
||||
statusText.setText(`Score: ${score.score} Streak: ${score.streak}`);
|
||||
};
|
||||
|
||||
const findAndRemove = <T>(arr: Array<T>, predicate: (el: T) => boolean): T | undefined => {
|
||||
const idx = arr.findIndex(predicate);
|
||||
if (idx === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return arr.splice(idx, 1)[0];
|
||||
};
|
||||
|
||||
export const handlePianoGameMsg = (
|
||||
msgs: Array<PianoCanvasMsg>,
|
||||
emitter: Phaser.GameObjects.Particles.ParticleEmitter | undefined,
|
||||
statusText: Phaser.GameObjects.Text | undefined
|
||||
) => {
|
||||
// this is temporary way of hanlding messages it works ok but is laggy when
|
||||
// pressing a lot of keys in a short time I will be using phaser events in the future I think
|
||||
const msg = findAndRemove(msgs, (msg) => {
|
||||
if (emitter && msg.type === 'noteTiming') {
|
||||
return true;
|
||||
} else if (statusText && msg.type === 'scoreInfo') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (msg) {
|
||||
if (msg.type === 'noteTiming') {
|
||||
handleNoteTimingMsg(msg.data as NoteTiming, emitter!);
|
||||
} else if (msg.type === 'scoreInfo') {
|
||||
handleScoreMsg(msg.data as PianoScoreInfo, statusText!);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { RootState, useSelector } from '../state/Store';
|
||||
import { Box, Pressable } from 'native-base';
|
||||
|
||||
const RowCustom = (props: Parameters<typeof Box>[0] & { onPress?: () => void }) => {
|
||||
const settings = useSelector((state: RootState) => state.settings.local);
|
||||
const systemColorMode = useColorScheme();
|
||||
const colorScheme = settings.colorScheme;
|
||||
|
||||
return (
|
||||
<Pressable onPress={props.onPress}>
|
||||
{({ isHovered, isPressed }) => (
|
||||
<Box
|
||||
{...props}
|
||||
py={3}
|
||||
my={1}
|
||||
bg={
|
||||
(colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark'
|
||||
? isHovered || isPressed
|
||||
? 'gray.800'
|
||||
: undefined
|
||||
: isHovered || isPressed
|
||||
? 'coolGray.200'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default RowCustom;
|
||||
@@ -1,20 +1,8 @@
|
||||
import {
|
||||
Box,
|
||||
Column,
|
||||
Row,
|
||||
Select,
|
||||
useBreakpointValue,
|
||||
useTheme,
|
||||
Text,
|
||||
View,
|
||||
Wrap,
|
||||
} from 'native-base';
|
||||
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';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import CheckboxBase from './UI/CheckboxBase';
|
||||
import { Dataset } from 'react-native-chart-kit/dist/HelperTypes';
|
||||
|
||||
type ScoreGraphProps = {
|
||||
// The result of the call to API.getSongHistory
|
||||
@@ -22,210 +10,68 @@ type ScoreGraphProps = {
|
||||
};
|
||||
|
||||
const formatScoreDate = (playDate: Date): string => {
|
||||
// const formattedDate = `${pad(playDate.getDay())}/${pad(playDate.getMonth())}`;
|
||||
// const formattedTime = `${pad(playDate.getHours())}:${pad(playDate.getMinutes())}`;
|
||||
return `${playDate.getDate()}/${playDate.getMonth()}`;
|
||||
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 layout = useWindowDimensions();
|
||||
const [selectedRange, setSelectedRange] = useState('3days');
|
||||
const [displayScore, setDisplayScore] = useState(true);
|
||||
const [displayPedals, setDisplayPedals] = useState(false);
|
||||
const [displayRightHand, setDisplayRightHand] = useState(false);
|
||||
const [displayLeftHand, setDisplayLeftHand] = useState(false);
|
||||
const [displayAccuracy, setDisplayAccuracy] = useState(false);
|
||||
const [displayArpeges, setDisplayArpeges] = useState(false);
|
||||
const [displayChords, setDisplayChords] = useState(false);
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: '3 derniers jours', value: '3days' },
|
||||
{ label: 'Dernière semaine', value: 'week' },
|
||||
{ label: 'Dernier mois', value: 'month' },
|
||||
];
|
||||
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;
|
||||
});
|
||||
|
||||
const filterData = () => {
|
||||
const oneWeekAgo = new Date();
|
||||
const oneMonthAgo = new Date();
|
||||
const threeDaysAgo = new Date();
|
||||
switch (selectedRange) {
|
||||
case 'week':
|
||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||
return scores.filter((item) => item.playDate >= oneWeekAgo);
|
||||
case 'month':
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
return scores.filter((item) => item.playDate > oneMonthAgo);
|
||||
default:
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
||||
return scores.filter((item) => item.playDate >= threeDaysAgo);
|
||||
}
|
||||
};
|
||||
|
||||
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 tempDatasets: Dataset[] = [];
|
||||
|
||||
const skills = [
|
||||
{
|
||||
title: 'Score',
|
||||
value: 'score',
|
||||
data: filterData().map(({ score }) => score),
|
||||
color: '#5f74f7',
|
||||
check: displayScore,
|
||||
setCheck: setDisplayScore,
|
||||
},
|
||||
{
|
||||
title: 'Pedals',
|
||||
value: 'pedals',
|
||||
color: '#ae84fb',
|
||||
data: filterData().map(({ score }) => (score > 100 ? score - 100 : score * 1.4)),
|
||||
check: displayPedals,
|
||||
setCheck: setDisplayPedals,
|
||||
},
|
||||
{
|
||||
title: 'Right hand',
|
||||
value: 'rightHand',
|
||||
data: filterData().map(({ score }) => (score > 10 ? score - 10 : score * 0.2)),
|
||||
color: '#a61455',
|
||||
check: displayRightHand,
|
||||
setCheck: setDisplayRightHand,
|
||||
},
|
||||
{
|
||||
title: 'Left hand',
|
||||
value: 'leftHand',
|
||||
data: filterData().map(({ score }) => (score > 50 ? score - 50 : score * 0.8)),
|
||||
color: '#ed4a51',
|
||||
check: displayLeftHand,
|
||||
setCheck: setDisplayLeftHand,
|
||||
},
|
||||
{
|
||||
title: 'Accuracy',
|
||||
value: 'accuracy',
|
||||
data: filterData().map(({ score }) => (score > 40 ? score - 40 : score * 0.4)),
|
||||
color: '#ff7a72',
|
||||
check: displayAccuracy,
|
||||
setCheck: setDisplayAccuracy,
|
||||
},
|
||||
{
|
||||
title: 'Arpeges',
|
||||
value: 'arpeges',
|
||||
data: filterData().map(({ score }) => (score > 200 ? score - 200 : score * 1.2)),
|
||||
color: '#ead93c',
|
||||
check: displayArpeges,
|
||||
setCheck: setDisplayArpeges,
|
||||
},
|
||||
{
|
||||
title: 'Chords',
|
||||
value: 'chords',
|
||||
data: filterData().map(({ score }) => (score > 50 ? score - 50 : score)),
|
||||
color: '#73d697',
|
||||
check: displayChords,
|
||||
setCheck: setDisplayChords,
|
||||
},
|
||||
];
|
||||
|
||||
for (const skill of skills) {
|
||||
if (skill.check) {
|
||||
tempDatasets.push({
|
||||
data: skill.data,
|
||||
color: () => skill.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<Column>
|
||||
<Row
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
<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),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Text style={{ padding: 10 }}>Skils</Text>
|
||||
<Wrap style={{ flexDirection: 'row', maxWidth: '100%', flex: 1 }}>
|
||||
{skills.map((skill) => (
|
||||
<View key={skill.value} style={{ padding: 10 }}>
|
||||
<CheckboxBase
|
||||
title={skill.title}
|
||||
value={skill.value}
|
||||
check={skill.check}
|
||||
setCheck={skill.setCheck}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<Box style={{ padding: 10 }}>
|
||||
<Select
|
||||
selectedValue={selectedRange}
|
||||
onValueChange={(itemValue) => setSelectedRange(itemValue)}
|
||||
defaultValue={'3days'}
|
||||
bgColor={'rgba(16,16,20,0.5)'}
|
||||
variant="filled"
|
||||
width={layout.width > 650 ? '200' : '150'}
|
||||
>
|
||||
{rangeOptions.map((option) => (
|
||||
<Select.Item
|
||||
key={option.label}
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</Wrap>
|
||||
</Row>
|
||||
<Box
|
||||
style={{ width: '100%', marginTop: 20 }}
|
||||
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
{tempDatasets.length > 0 && (
|
||||
<LineChart
|
||||
data={{
|
||||
labels: isSmall
|
||||
? []
|
||||
: filterData().map(({ playDate }) => formatScoreDate(playDate)),
|
||||
datasets: tempDatasets,
|
||||
}}
|
||||
width={containerWidth}
|
||||
height={300} // Completely arbitrary
|
||||
transparent={true}
|
||||
yAxisSuffix=" pts"
|
||||
chartConfig={{
|
||||
propsForLabels: {
|
||||
fontFamily: 'Lexend',
|
||||
},
|
||||
propsForVerticalLabels: {
|
||||
rotation: -90,
|
||||
},
|
||||
propsForBackgroundLines: {
|
||||
strokeDasharray: '',
|
||||
strokeWidth: '1',
|
||||
color: '#fff000',
|
||||
},
|
||||
decimalPlaces: 0,
|
||||
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
|
||||
labelColor: () => theme.colors.white,
|
||||
propsForDots: {
|
||||
r: '6',
|
||||
strokeWidth: '2',
|
||||
},
|
||||
}}
|
||||
bezier
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Column>
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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' | 'favorites';
|
||||
export type Filter = 'artist' | 'song' | 'genre' | 'all';
|
||||
|
||||
type FilterButton = {
|
||||
name: string;
|
||||
@@ -42,11 +42,6 @@ const SearchBar = () => {
|
||||
callback: () => updateFilter('all'),
|
||||
id: 'all',
|
||||
},
|
||||
{
|
||||
name: translate('favoriteFilter'),
|
||||
callback: () => updateFilter('favorites'),
|
||||
id: 'favorites',
|
||||
},
|
||||
{
|
||||
name: translate('artistFilter'),
|
||||
callback: () => updateFilter('artist'),
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Pressable,
|
||||
Box,
|
||||
Card,
|
||||
Image,
|
||||
Flex,
|
||||
useBreakpointValue,
|
||||
Column,
|
||||
ScrollView,
|
||||
} from 'native-base';
|
||||
import { SafeAreaView } from 'react-native';
|
||||
import { SafeAreaView, useColorScheme } from 'react-native';
|
||||
import { RootState, useSelector } from '../state/Store';
|
||||
import { SearchContext } from '../views/SearchView';
|
||||
import { useQueries, useQuery } from '../Queries';
|
||||
import { translate } from '../i18n/i18n';
|
||||
import API from '../API';
|
||||
import LoadingComponent, { LoadingView } from './Loading';
|
||||
import LoadingComponent from './Loading';
|
||||
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';
|
||||
import FavSongRow from './FavSongRow';
|
||||
import { LikedSongWithDetails } from '../models/LikedSong';
|
||||
|
||||
const swaToSongCardProps = (song: SongWithArtist) => ({
|
||||
songId: song.id,
|
||||
@@ -35,6 +37,101 @@ 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(
|
||||
@@ -112,14 +209,8 @@ type SongsSearchComponentProps = {
|
||||
};
|
||||
|
||||
const SongsSearchComponent = (props: SongsSearchComponentProps) => {
|
||||
const navigation = useNavigation();
|
||||
const { songData } = React.useContext(SearchContext);
|
||||
const favoritesQuery = useQuery(API.getLikedSongs());
|
||||
|
||||
const handleFavoriteButton = async (state: boolean, songId: number): Promise<void> => {
|
||||
if (state == false) await API.removeLikedSong(songId);
|
||||
else await API.addLikedSong(songId);
|
||||
};
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
@@ -132,12 +223,6 @@ const SongsSearchComponent = (props: SongsSearchComponentProps) => {
|
||||
<SongRow
|
||||
key={index}
|
||||
song={comp}
|
||||
isLiked={
|
||||
!favoritesQuery.data?.find((query) => query?.songId == comp.id)
|
||||
}
|
||||
handleLike={(state: boolean, songId: number) =>
|
||||
handleFavoriteButton(state, songId)
|
||||
}
|
||||
onPress={() => {
|
||||
API.createSearchHistoryEntry(comp.name, 'song');
|
||||
navigation.navigate('Song', { songId: comp.id });
|
||||
@@ -167,17 +252,15 @@ 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((a) => ({
|
||||
image: API.getArtistIllustration(a.id),
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(a.name, 'artist');
|
||||
navigation.navigate('Artist', { artistId: a.id });
|
||||
},
|
||||
}))}
|
||||
cardComponent={ArtistCard}
|
||||
/>
|
||||
) : (
|
||||
@@ -204,7 +287,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
|
||||
id: g.id,
|
||||
onPress: () => {
|
||||
API.createSearchHistoryEntry(g.name, 'genre');
|
||||
navigation.navigate('Genre', { genreId: g.id });
|
||||
navigation.navigate('Home');
|
||||
},
|
||||
}))}
|
||||
cardComponent={GenreCard}
|
||||
@@ -216,52 +299,6 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesComponent = () => {
|
||||
const navigation = useNavigation();
|
||||
const favoritesQuery = useQuery(API.getLikedSongs());
|
||||
const songQueries = useQueries(
|
||||
favoritesQuery.data
|
||||
?.map((favorite) => favorite.songId)
|
||||
.map((songId) => API.getSong(songId)) ?? []
|
||||
);
|
||||
|
||||
const favSongWithDetails = favoritesQuery?.data
|
||||
?.map((favorite) => ({
|
||||
...favorite,
|
||||
details: songQueries.find((query) => query.data?.id == favorite.songId)?.data,
|
||||
}))
|
||||
.filter((favorite) => favorite.details !== undefined)
|
||||
.map((likedSong) => likedSong as LikedSongWithDetails);
|
||||
|
||||
if (favoritesQuery.isError) {
|
||||
navigation.navigate('Error');
|
||||
return <></>;
|
||||
}
|
||||
if (!favoritesQuery.data) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<Text fontSize="xl" fontWeight="bold" mt={4}>
|
||||
{translate('songsFilter')}
|
||||
</Text>
|
||||
<Box>
|
||||
{favSongWithDetails?.map((songData) => (
|
||||
<FavSongRow
|
||||
key={songData.id}
|
||||
FavSong={songData}
|
||||
onPress={() => {
|
||||
API.createSearchHistoryEntry(songData.details!.name, 'song'); //todo
|
||||
navigation.navigate('Song', { songId: songData.details!.id }); //todo
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const AllComponent = () => {
|
||||
const screenSize = useBreakpointValue({ base: 'small', md: 'big' });
|
||||
const isMobileView = screenSize == 'small';
|
||||
@@ -307,8 +344,6 @@ const FilterSwitch = () => {
|
||||
return <ArtistSearchComponent />;
|
||||
case 'genre':
|
||||
return <GenreSearchComponent />;
|
||||
case 'favorites':
|
||||
return <FavoritesComponent />;
|
||||
default:
|
||||
return <Text>Something very bad happened: {currentFilter}</Text>;
|
||||
}
|
||||
@@ -316,8 +351,7 @@ const FilterSwitch = () => {
|
||||
|
||||
export const SearchResultComponent = () => {
|
||||
const { stringQuery } = React.useContext(SearchContext);
|
||||
const { filter } = React.useContext(SearchContext);
|
||||
const shouldOutput = !!stringQuery.trim() || filter == 'favorites';
|
||||
const shouldOutput = !!stringQuery.trim();
|
||||
|
||||
return shouldOutput ? (
|
||||
<Box p={5}>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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';
|
||||
|
||||
type SongRowProps = {
|
||||
song: Song | SongWithArtist; // TODO: remove Song
|
||||
isLiked: boolean;
|
||||
onPress: () => void;
|
||||
handleLike: (state: boolean, songId: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const SongRow = ({ song, onPress, handleLike, isLiked }: SongRowProps) => {
|
||||
return (
|
||||
<RowCustom width={'100%'}>
|
||||
<HStack px={2} space={5} justifyContent={'space-between'}>
|
||||
<Image
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
pl={10}
|
||||
style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }}
|
||||
source={{ uri: song.cover }}
|
||||
alt={song.name}
|
||||
borderColor={'white'}
|
||||
borderWidth={1}
|
||||
/>
|
||||
<HStack
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
space={6}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flexShrink: 1,
|
||||
}}
|
||||
isTruncated
|
||||
pl={5}
|
||||
maxW={'100%'}
|
||||
bold
|
||||
fontSize="md"
|
||||
>
|
||||
{song.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
fontSize={'sm'}
|
||||
>
|
||||
{song.artistId ?? 'artist'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
colorScheme="rose"
|
||||
variant={'ghost'}
|
||||
borderRadius={'full'}
|
||||
onPress={async () => {
|
||||
await handleLike(isLiked, song.id);
|
||||
}}
|
||||
_icon={{
|
||||
as: MaterialIcons,
|
||||
name: isLiked ? 'favorite-outline' : 'favorite',
|
||||
}}
|
||||
/>
|
||||
<TextButton
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
translate={{ translationKey: 'playBtn' }}
|
||||
colorScheme="primary"
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
mr={5}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</HStack>
|
||||
</RowCustom>
|
||||
);
|
||||
};
|
||||
|
||||
export default SongRow;
|
||||
@@ -1,147 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, ActivityIndicator, View, Image, StyleProp, ViewStyle } from 'react-native';
|
||||
import InteractiveBase from './InteractiveBase';
|
||||
import { Text, useTheme } from 'native-base';
|
||||
import { Icon } from 'iconsax-react-native';
|
||||
|
||||
interface ButtonProps {
|
||||
title?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPress?: () => Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
icon?: Icon;
|
||||
iconImage?: string;
|
||||
type?: 'filled' | 'outlined' | 'menu';
|
||||
}
|
||||
|
||||
const ButtonBase: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
style,
|
||||
onPress,
|
||||
isDisabled,
|
||||
icon,
|
||||
iconImage,
|
||||
type = 'filled',
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const styleButton = StyleSheet.create({
|
||||
Default: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: colors.primary[400],
|
||||
},
|
||||
onHover: {
|
||||
scale: 1.02,
|
||||
shadowOpacity: 0.37,
|
||||
shadowRadius: 7.49,
|
||||
elevation: 12,
|
||||
backgroundColor: colors.primary[500],
|
||||
},
|
||||
onPressed: {
|
||||
scale: 0.98,
|
||||
shadowOpacity: 0.23,
|
||||
shadowRadius: 2.62,
|
||||
elevation: 4,
|
||||
backgroundColor: colors.primary[600],
|
||||
},
|
||||
Disabled: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: colors.primary[400],
|
||||
},
|
||||
});
|
||||
|
||||
const styleMenu = StyleSheet.create({
|
||||
Default: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: 'rgba(16,16,20,0.5)',
|
||||
},
|
||||
onHover: {
|
||||
scale: 1.01,
|
||||
shadowOpacity: 0.37,
|
||||
shadowRadius: 7.49,
|
||||
elevation: 12,
|
||||
backgroundColor: 'rgba(16,16,20,0.4)',
|
||||
},
|
||||
onPressed: {
|
||||
scale: 0.99,
|
||||
shadowOpacity: 0.23,
|
||||
shadowRadius: 2.62,
|
||||
elevation: 4,
|
||||
backgroundColor: 'rgba(16,16,20,0.6)',
|
||||
},
|
||||
Disabled: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: 'rgba(16,16,20,0.5)',
|
||||
},
|
||||
});
|
||||
|
||||
const typeToStyleAnimator = { filled: styleButton, outlined: styleButton, menu: styleMenu };
|
||||
const MyIcon: Icon = icon as Icon;
|
||||
|
||||
return (
|
||||
<InteractiveBase
|
||||
style={[styles.container, style]}
|
||||
styleAnimate={typeToStyleAnimator[type]}
|
||||
onPress={async () => {
|
||||
if (onPress && !isDisabled) {
|
||||
setLoading(true);
|
||||
await onPress();
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
isOutlined={type === 'outlined'}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
style={styles.content}
|
||||
size="small"
|
||||
color={type === 'outlined' ? '#6075F9' : '#FFFFFF'}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{icon && (
|
||||
<MyIcon size={'18'} color={type === 'outlined' ? '#6075F9' : '#FFFFFF'} />
|
||||
)}
|
||||
{iconImage && <Image source={{ uri: iconImage }} style={styles.icon} />}
|
||||
{title && <Text style={styles.text}>{title}</Text>}
|
||||
</View>
|
||||
)}
|
||||
</InteractiveBase>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
content: {
|
||||
padding: 10,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonBase;
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, StyleProp, ViewStyle } from 'react-native';
|
||||
import InteractiveBase from './InteractiveBase';
|
||||
import { Checkbox } from 'native-base';
|
||||
|
||||
interface CheckboxProps {
|
||||
title: string;
|
||||
value: string;
|
||||
// color: string;
|
||||
check: boolean;
|
||||
setCheck: (value: boolean) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const CheckboxBase: React.FC<CheckboxProps> = ({
|
||||
title,
|
||||
value,
|
||||
// color,
|
||||
style,
|
||||
check,
|
||||
setCheck,
|
||||
}) => {
|
||||
const styleGlassmorphism = StyleSheet.create({
|
||||
Default: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: 'rgba(16,16,20,0.5)',
|
||||
},
|
||||
onHover: {
|
||||
scale: 1.01,
|
||||
shadowOpacity: 0.37,
|
||||
shadowRadius: 7.49,
|
||||
elevation: 12,
|
||||
backgroundColor: 'rgba(16,16,20,0.4)',
|
||||
},
|
||||
onPressed: {
|
||||
scale: 0.99,
|
||||
shadowOpacity: 0.23,
|
||||
shadowRadius: 2.62,
|
||||
elevation: 4,
|
||||
backgroundColor: 'rgba(16,16,20,0.6)',
|
||||
},
|
||||
Disabled: {
|
||||
scale: 1,
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
backgroundColor: 'rgba(16,16,20,0.5)',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<InteractiveBase
|
||||
style={[styles.container, style]}
|
||||
styleAnimate={styleGlassmorphism}
|
||||
onPress={async () => {
|
||||
setCheck(!check);
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingVertical: 5, paddingHorizontal: 10 }}>
|
||||
<Checkbox isChecked={check} style={styles.content} value={value}>
|
||||
{title}
|
||||
</Checkbox>
|
||||
</View>
|
||||
</InteractiveBase>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
content: {
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default CheckboxBase;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Pressable } from 'native-base';
|
||||
import React, { useRef } from 'react';
|
||||
import { Animated, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
interface InteractiveBaseProps {
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
isOutlined?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
styleAnimate: {
|
||||
Default: {
|
||||
scale: number;
|
||||
shadowOpacity: number;
|
||||
shadowRadius: number;
|
||||
elevation: number;
|
||||
backgroundColor: string;
|
||||
};
|
||||
onHover: {
|
||||
scale: number;
|
||||
shadowOpacity: number;
|
||||
shadowRadius: number;
|
||||
elevation: number;
|
||||
backgroundColor: string;
|
||||
};
|
||||
onPressed: {
|
||||
scale: number;
|
||||
shadowOpacity: number;
|
||||
shadowRadius: number;
|
||||
elevation: number;
|
||||
backgroundColor: string;
|
||||
};
|
||||
Disabled: {
|
||||
scale: number;
|
||||
shadowOpacity: number;
|
||||
shadowRadius: number;
|
||||
elevation: number;
|
||||
backgroundColor: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const InteractiveBase: React.FC<InteractiveBaseProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
style,
|
||||
styleAnimate,
|
||||
isDisabled = false,
|
||||
isOutlined = false,
|
||||
}) => {
|
||||
const scaleAnimator = useRef(new Animated.Value(1)).current;
|
||||
const scaleValue = scaleAnimator.interpolate({
|
||||
inputRange: [0, 1, 2],
|
||||
outputRange: [
|
||||
styleAnimate.Default.scale,
|
||||
styleAnimate.onHover.scale,
|
||||
styleAnimate.onPressed.scale,
|
||||
],
|
||||
});
|
||||
const shadowOpacityAnimator = useRef(new Animated.Value(0)).current;
|
||||
const shadowOpacityValue = shadowOpacityAnimator.interpolate({
|
||||
inputRange: [0, 1, 2],
|
||||
outputRange: [
|
||||
styleAnimate.Default.shadowOpacity,
|
||||
styleAnimate.onHover.shadowOpacity,
|
||||
styleAnimate.onPressed.shadowOpacity,
|
||||
],
|
||||
});
|
||||
const shadowRadiusAnimator = useRef(new Animated.Value(0)).current;
|
||||
const shadowRadiusValue = shadowRadiusAnimator.interpolate({
|
||||
inputRange: [0, 1, 2],
|
||||
outputRange: [
|
||||
styleAnimate.Default.shadowRadius,
|
||||
styleAnimate.onHover.shadowRadius,
|
||||
styleAnimate.onPressed.shadowRadius,
|
||||
],
|
||||
});
|
||||
const elevationAnimator = useRef(new Animated.Value(0)).current;
|
||||
const elevationValue = elevationAnimator.interpolate({
|
||||
inputRange: [0, 1, 2],
|
||||
outputRange: [
|
||||
styleAnimate.Default.elevation,
|
||||
styleAnimate.onHover.elevation,
|
||||
styleAnimate.onPressed.elevation,
|
||||
],
|
||||
});
|
||||
const backgroundColorAnimator = useRef(new Animated.Value(0)).current;
|
||||
const backgroundColorValue = backgroundColorAnimator.interpolate({
|
||||
inputRange: [0, 1, 2],
|
||||
outputRange: [
|
||||
styleAnimate.Default.backgroundColor,
|
||||
styleAnimate.onHover.backgroundColor,
|
||||
styleAnimate.onPressed.backgroundColor,
|
||||
],
|
||||
});
|
||||
|
||||
// Mouse Enter
|
||||
const handleMouseEnter = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimator, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundColorAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowRadiusAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowOpacityAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(elevationAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
// Mouse Down
|
||||
const handlePressIn = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimator, {
|
||||
toValue: 2,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundColorAnimator, {
|
||||
toValue: 2,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowRadiusAnimator, {
|
||||
toValue: 2,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowOpacityAnimator, {
|
||||
toValue: 2,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(elevationAnimator, {
|
||||
toValue: 2,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
// Mouse Up
|
||||
const handlePressOut = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimator, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundColorAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowRadiusAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowOpacityAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(elevationAnimator, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
if (onPress && !isDisabled) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
// Mouse Leave
|
||||
const handleMouseLeave = () => {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimator, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backgroundColorAnimator, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowRadiusAnimator, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(shadowOpacityAnimator, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(elevationAnimator, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
const animatedStyle = {
|
||||
backgroundColor: isOutlined ? 'rgba(0,0,0,0.3)' : backgroundColorValue,
|
||||
borderColor: isOutlined ? backgroundColorValue : 'transparent',
|
||||
borderWidth: 2,
|
||||
transform: [{ scale: scaleValue }],
|
||||
shadowOpacity: shadowOpacityValue,
|
||||
shadowRadius: shadowRadiusValue,
|
||||
elevation: elevationValue,
|
||||
};
|
||||
|
||||
const disableStyle = {
|
||||
backgroundColor: isOutlined ? 'rgba(0,0,0,0.3)' : styleAnimate.Disabled.backgroundColor,
|
||||
borderColor: isOutlined ? styleAnimate.Disabled.backgroundColor : 'transparent',
|
||||
borderWidth: 2,
|
||||
scale: styleAnimate.Disabled.scale,
|
||||
shadowOpacity: styleAnimate.Disabled.shadowOpacity,
|
||||
shadowRadius: styleAnimate.Disabled.shadowRadius,
|
||||
elevation: styleAnimate.Disabled.elevation,
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, isDisabled ? disableStyle : animatedStyle]}>
|
||||
<Pressable
|
||||
disabled={isDisabled}
|
||||
onHoverIn={handleMouseEnter}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onHoverOut={handleMouseLeave}
|
||||
style={styles.container}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default InteractiveBase;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React, { ReactNode, FunctionComponent } from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
linkText: {
|
||||
textDecorationLine: 'underline',
|
||||
color: '#A3AFFC',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
interface LinkBaseProps {
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const LinkBase: FunctionComponent<LinkBaseProps> = ({ children, onPress }) => (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text style={styles.linkText}>{children}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export default LinkBase;
|
||||
@@ -1,113 +0,0 @@
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Center, Flex, Stack, View, Text, Wrap, Image } from 'native-base';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Linking, useWindowDimensions } from 'react-native';
|
||||
import ButtonBase from './ButtonBase';
|
||||
import { translate } from '../../i18n/i18n';
|
||||
import API from '../../API';
|
||||
import SeparatorBase from './SeparatorBase';
|
||||
import LinkBase from './LinkBase';
|
||||
import ImageBanner from '../../assets/banner.jpg';
|
||||
|
||||
interface ScaffoldAuthProps {
|
||||
title: string;
|
||||
description: string;
|
||||
form: React.ReactNode[];
|
||||
submitButton: React.ReactNode;
|
||||
link: { text: string; description: string; onPress: () => void };
|
||||
}
|
||||
|
||||
const ScaffoldAuth: FunctionComponent<ScaffoldAuthProps> = ({
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
submitButton,
|
||||
link,
|
||||
}) => {
|
||||
const layout = useWindowDimensions();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
style={{ flex: 1, backgroundColor: '#101014' }}
|
||||
>
|
||||
<Center style={{ flex: 1 }}>
|
||||
<View style={{ width: '100%', maxWidth: 420, padding: 16 }}>
|
||||
<Stack
|
||||
space={8}
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
style={{ width: '100%', paddingBottom: 40 }}
|
||||
>
|
||||
<Text fontSize="4xl" textAlign="center">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="lg" textAlign="center">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
space={5}
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<ButtonBase
|
||||
style={{ width: '100%' }}
|
||||
type="outlined"
|
||||
iconImage="https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/2008px-Google_%22G%22_Logo.svg.png"
|
||||
title={translate('continuewithgoogle')}
|
||||
onPress={() => Linking.openURL(`${API.baseUrl}/auth/login/google`)}
|
||||
/>
|
||||
<SeparatorBase>or</SeparatorBase>
|
||||
<Stack
|
||||
space={3}
|
||||
justifyContent="center"
|
||||
alignContent="center"
|
||||
alignItems="center"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{form}
|
||||
</Stack>
|
||||
{submitButton}
|
||||
<Wrap style={{ flexDirection: 'row', justifyContent: 'center' }}>
|
||||
<Text>{link.description}</Text>
|
||||
<LinkBase onPress={link.onPress}>{link.text}</LinkBase>
|
||||
</Wrap>
|
||||
</Stack>
|
||||
</View>
|
||||
</Center>
|
||||
{layout.width > 650 ? (
|
||||
<View style={{ width: '50%', height: '100%', padding: 16 }}>
|
||||
<Image
|
||||
source={ImageBanner}
|
||||
alt="banner page"
|
||||
style={{ width: '100%', height: '100%', borderRadius: 8 }}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
colors={['#101014', '#6075F9']}
|
||||
style={{
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
zIndex: -2,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScaffoldAuth;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 2,
|
||||
},
|
||||
text: {
|
||||
color: 'white',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
});
|
||||
|
||||
interface SeparatorBaseProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SeparatorBase: FunctionComponent<SeparatorBaseProps> = ({ children }) => (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.line} />
|
||||
<Text style={styles.text}>{children}</Text>
|
||||
<View style={styles.line} />
|
||||
</View>
|
||||
);
|
||||
|
||||
export default SeparatorBase;
|
||||