141 Commits

Author SHA1 Message Date
60a2c4c817 wip 2023-09-23 10:53:13 +02:00
c1073cf337 Fix log error for images 2023-09-21 17:03:03 +02:00
GitBluub
4a4f9e2a55 fix: email template 2023-09-21 15:23:07 +02:00
3860c9f72a Fix prettier 2023-09-21 15:11:21 +02:00
b02b23a978 Fix signup mismatch 2023-09-21 15:06:38 +02:00
5b0c1f8992 Fix verify mail 2023-09-21 14:38:51 +02:00
Bluub
8155549031 feat: back password reset email (#277) 2023-09-21 14:29:09 +02:00
Amaury
1ca4633360 Merge pull request #271 from Chroma-Case/feature/adc/#242-liked-songs
Feature/adc/#242 liked songs
2023-09-21 12:05:16 +02:00
danis
bb304fa8cd merge main into liked songs 2023-09-21 12:04:30 +02:00
Bluub
9a1f1f78cb Merge pull request #275 from Chroma-Case/logs 2023-09-21 00:49:24 +02:00
GitBluub
96bb830600 fix: try to fix scoro tests 2023-09-21 00:43:45 +02:00
Bluub
1333b74001 Merge branch 'main' into logs 2023-09-21 00:35:37 +02:00
GitBluub
ece87dbdb9 fix: try to fix scoro tests 2023-09-21 00:26:23 +02:00
GitBluub
e82a6b1dd6 fix: try to fix scoro tests 2023-09-21 00:17:02 +02:00
GitBluub
cd2e119dc6 fix: separate file for logging containers 2023-09-21 00:09:41 +02:00
Clément Le Bihan
c9928f1cce Merge pull request #276 from Chroma-Case/redesign-settings 2023-09-21 00:01:13 +02:00
Clément Le Bihan
7aac3922d6 Merge remote-tracking branch 'origin' into redesign-settings 2023-09-20 23:59:51 +02:00
GitBluub
82403c811e fix: format 2023-09-20 23:54:04 +02:00
GitBluub
230c60bcd0 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
177e903b07 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
a11c236753 fix: ok instead of created 201 -> 200 2023-09-20 23:46:38 +02:00
GitBluub
29ef585410 doc: genre, lesson and history controller 2023-09-20 23:46:38 +02:00
GitBluub
f8be2c2462 doc: artist and album controller 2023-09-20 23:46:38 +02:00
GitBluub
7d27af1e2d doc: search controller 2023-09-20 23:46:38 +02:00
GitBluub
258fe91ae7 doc: song controller 2023-09-20 23:46:38 +02:00
GitBluub
711b5d583b doc: users controller 2023-09-20 23:46:38 +02:00
GitBluub
4416808056 doc: auth controller 2023-09-20 23:46:38 +02:00
GitBluub
979c27c087 feat: doc for app controller 2023-09-20 23:46:38 +02:00
GitBluub
b3117886cf fix: class gen folder in gitignore 2023-09-20 23:46:38 +02:00
GitBluub
1c248fa479 fix: model for the plagination in swagger 2023-09-20 23:46:38 +02:00
GitBluub
ec62f4b085 feat: prisma class generator and models in the swagger 2023-09-20 23:46:38 +02:00
GitBluub
04bad30aaa feat: install prisma class generator 2023-09-20 23:46:38 +02:00
mathysPaul
e5a52d0f94 fix checkbox Profile off-screen 2023-09-20 18:34:20 +02:00
mathysPaul
68c6c6fa11 fixing error from CI 2023-09-20 17:59:36 +02:00
mathysPaul
94a64d16e6 Redesign profil with datafake for skills 2023-09-20 17:40:52 +02:00
7aa7f50ecb Fix prod nginx 2023-09-20 14:58:09 +02:00
ee8e0e26db Fix eslint and bad reverify mail issue 2023-09-20 14:58:09 +02:00
31b965e8f6 Add volume/enable state and follow the music's bpm for the metronome 2023-09-20 14:58:09 +02:00
94658d4379 Add static assets to nginx 2023-09-20 14:58:09 +02:00
Clément Le Bihan
49a735631a prettied 2023-09-20 13:39:26 +02:00
Clément Le Bihan
1905daec60 MainHomeCard is now displaying the first 4 songs 2023-09-20 13:39:26 +02:00
Clément Le Bihan
7a1f4fb787 Fix to really allow guest accounts empty strings are transformed to null values 2023-09-20 13:39:26 +02:00
Clément Le Bihan
f3cdba34fb Now usign real play history for the TabNavigator Desktop 2023-09-20 13:39:26 +02:00
Clément Le Bihan
5b7cb6746d Added specific fontSizes for each card 2023-09-20 13:39:26 +02:00
Clément Le Bihan
6e3e73982f Added callback for onPress for the SongCardInfos and replaced the button to have the play icon more centered but some state issue 2023-09-20 13:39:26 +02:00
Clément Le Bihan
8e5c65e6f2 Added SongCardInfo for the V2 design and type fixes 2023-09-20 13:39:26 +02:00
Clément Le Bihan
94875d4c7f trying golden ratio 2023-09-20 13:39:26 +02:00
Clément Le Bihan
e817021ede fix type errors 2023-09-20 13:39:26 +02:00
Clément Le Bihan
dcca1b1f1c Added phone and responsive support on the tabnavigation added callapsables fixed colorscheme and setting background color 2023-09-20 13:39:26 +02:00
Clément Le Bihan
c0c2918e72 Started navigation 2023-09-20 13:39:26 +02:00
mathysPaul
973f9bf5b3 redesign AuthenticationView 2023-09-20 10:27:24 +02:00
GitBluub
162fc9148f grafana: auto setup of dashboard 2023-09-20 00:26:15 +02:00
GitBluub
57d646f6eb scoro: direct log to loki not working 2023-09-20 00:25:26 +02:00
mathysPaul
6768b0b2a6 merge main 2023-09-19 19:22:25 +02:00
mathysPaul
fa14d1f979 Fixing error prettier redesign CI 2023-09-19 18:43:38 +02:00
mathysPaul
c4ca2e509e Fixing redesign-settings prettier & lint => CI 2023-09-19 18:23:31 +02:00
mathysPaul
1abfbf391f Fixing error prettier redesign CI 2023-09-19 17:36:19 +02:00
mathysPaul
073ff033f3 Fixing error redesign CI 2023-09-19 17:12:49 +02:00
GitBluub
23e5941700 scoro: log directly to loki 2023-09-19 17:11:42 +02:00
Clément Le Bihan
027d450579 Forgot a merge conflict 2023-09-19 15:19:42 +02:00
Clément Le Bihan
ad9bbbc2b9 Cleanup random 2023-09-19 15:19:42 +02:00
Clément Le Bihan
58af78b1d3 prettied phaserCanvas.ts 2023-09-19 15:19:42 +02:00
Clément Le Bihan
09d2da8eec Fixed scaling issue with the cursor position texture size is still a concern 2023-09-19 15:19:42 +02:00
Clément Le Bihan
8abaaf6624 style the scaling not working to fix 2023-09-19 15:19:42 +02:00
Clément Le Bihan
3c3697be61 fix test back for duplicated user 2023-09-19 15:19:42 +02:00
Clément Le Bihan
073c00a35e Fixed a bug when current streak is 0 and Linter fix 2023-09-19 15:19:42 +02:00
Clément Le Bihan
58d761c359 prettier cleanup 2023-09-19 15:19:42 +02:00
Clément Le Bihan
aaaf73f632 PR cleanup 2023-09-19 15:19:42 +02:00
Clément Le Bihan
f83043a9c9 Handling in satisfactory manner scoro messages 2023-09-19 15:19:42 +02:00
Clément Le Bihan
cea6d8d0bc Added the message pinao system reusing a react context for simplicity and emitting note timing messages when scoro gives the result 2023-09-19 15:19:42 +02:00
Clément Le Bihan
607c35b621 Added first effect of particules 2023-09-19 15:19:42 +02:00
Clément Le Bihan
13d0be4586 Small QoL fixes thare were really needed 2023-09-19 15:19:42 +02:00
danis
3e1e41f117 pretty 2023-09-19 09:39:54 +02:00
danis
8f9d7e4a85 typo 2023-09-19 09:37:00 +02:00
mathysPaul
1e504c8982 Redesign settings 2023-09-19 03:54:12 +02:00
danis
e56436db3a merging main into feature/adc/#242-liked-songs 2023-09-18 16:49:47 +02:00
danis
bc227fb0ea pretty + better handling + handling in artist detail view 2023-09-18 16:45:03 +02:00
Clément Le Bihan
49bc4f9f45 Update front/views/StartPageView.tsx 2023-09-18 15:37:58 +02:00
Arthur Jamet
73076c4b28 Front: Recover package.json 2023-09-18 15:37:58 +02:00
Arthur Jamet
8732972b3f Front: Recover yarn.lock 2023-09-18 15:37:58 +02:00
Arthur Jamet
cd9d64e501 Front: Prettier 2023-09-18 15:37:58 +02:00
Arthur Jamet
62bf7ec035 Front: Apply New Color, Button and Link Style 2023-09-18 15:37:58 +02:00
Arthur Jamet
659f5d5d84 Front: Setup New Font 2023-09-18 15:37:58 +02:00
Arthur Jamet
bbc53f04de Front: Get Rid of external image, load local assets 2023-09-18 15:37:58 +02:00
danis
431427d7ad fixed mirgation + back-end + front end filter, heart shaped button and special FavSongRow 2023-09-17 20:57:10 +02:00
GitBluub
611ab57c5d scoro: game uuid for logging and bug fixing 2023-09-16 16:55:55 +02:00
bc13c10f1a Fix ci 2023-09-15 17:57:03 +02:00
91c9e2b295 Update .env.example to use dummy values for the ci 2023-09-15 17:57:03 +02:00
585be2aa19 Fix prettier warnings 2023-09-15 17:57:03 +02:00
654022b48a Update .env.example 2023-09-15 17:57:03 +02:00
afab03baf8 Add a button to resend verified mail 2023-09-15 17:57:03 +02:00
a52c10fc2c Add verified badge and page on the front 2023-09-15 17:57:03 +02:00
f2ed598865 Use a fixed python version for the scorometer 2023-09-15 17:57:03 +02:00
02fc8175f4 Send mails on account creation 2023-09-15 17:57:03 +02:00
Arthur Jamet
628e50a48d Merge pull request #257 from Chroma-Case/feature/adc/#224-genre-view
Feature/adc/#224 genre view
2023-09-14 15:33:40 +02:00
Arthur Jamet
70ab56ce3a Front: Remove unused value 2023-09-14 11:41:38 +02:00
Arthur Jamet
1fefe7912d Front: Run Pretty 2023-09-14 11:37:50 +02:00
danis
c21f5f0659 Merge branch 'feature/adc/#224-genre-view' into feature/adc/#242-liked-songs 2023-09-13 13:23:16 +02:00
danis
46ef0a7f1b remove expo-linear-gradient 2023-09-12 22:05:31 +02:00
danis
b43c64962a favorites search view filter + song query from favorites data 2023-09-10 14:48:39 +02:00
danis
64640eda55 lints fix 2023-09-09 19:18:30 +02:00
danis
a6d9cb3b40 run prettier 2023-09-09 18:55:32 +02:00
danis
b61541f7b8 fix PR III 2023-09-09 17:52:22 +02:00
danis
3ff523560b fix PR II 2023-09-09 17:51:18 +02:00
danis
b61968706d fix PR I 2023-09-09 14:25:43 +02:00
Arthur Jamet
2f27278d3a Front: Pretty 2023-09-08 17:53:23 +02:00
Arthur Jamet
e1ab9fe118 Front: Fix an error that occured on prod, caused by the avatar's url 2023-09-08 17:53:23 +02:00
Arthur Jamet
b1d0415ba0 Front: Fix genre view 2023-09-07 17:10:18 +02:00
Arthur Jamet
8ab85ab689 Front: remove file64 dependency 2023-09-07 17:06:27 +02:00
danis
16cd794e3b trial for artist name 2023-09-07 10:31:03 +02:00
danis
f85c30a53b clean code VI 2023-09-06 17:07:16 +02:00
danis
6da96ed886 clean code V 2023-09-06 17:00:36 +02:00
danis
852fbd5c87 clean code IV 2023-09-06 16:39:38 +02:00
danis
5cec62d1b1 search view update 2023-09-06 16:38:44 +02:00
danis
7e866f9826 clean code III 2023-09-06 15:59:50 +02:00
danis
2f50f694f3 clean code 2023-09-06 15:57:38 +02:00
danis
c9d3ef88e7 clean code + search history handler fix 2023-09-05 13:44:30 +02:00
danis
0ba3bec5aa Merge branch 'main' into feature/adc/#224-genre-view 2023-09-05 09:41:20 +02:00
danis
539c35c903 song cards routing fix 2023-09-05 09:36:11 +02:00
danis
e1463d41b9 actual data from db tho needs better design care 2023-09-05 09:33:31 +02:00
danis
01394056a6 Merge branch 'feature/adc/artist-view' into feature/adc/#224-genre-view 2023-09-04 14:24:21 +02:00
danis
1396fcb39c artist name fix 2023-09-04 11:05:33 +02:00
danis
c81f8df61c prisma migration + back auth/me/likes + front API add and get methods for liked song 2023-08-30 13:06:25 +02:00
danis
1255343b97 artist view + moved components 2023-08-12 11:16:22 +02:00
danis
f7562c18bd basic genre details view 2023-08-12 10:43:02 +02:00
GitBluub
dc398d6e06 rm useless file 2023-07-26 22:22:03 +09:00
GitBluub
d5da112a01 scorometer create uuid 2023-07-26 22:21:36 +09:00
GitBluub
96048bd671 back logging every request 2023-07-26 22:21:21 +09:00
GitBluub
dcdc6b196d grafana setup and dashboard json 2023-07-26 22:21:00 +09:00
GitBluub
2ec95dd3c3 wip 2023-07-23 18:14:11 +09:00
danis
bf09a25eb5 linear gradient 2023-07-11 10:06:55 +02:00
danis
373128ba53 broke my glasses 2023-07-10 23:12:37 +02:00
danis
3a09d10d3b you miss 100% of the shots you dont take 2023-07-09 23:24:31 +02:00
Arthur Jamet
87de52cae0 Front: 'Get Song By Artist' Query: fix typings 2023-07-05 14:18:31 +01:00
Arthur Jamet
931fe13eee Merge branch 'main' of github.com:Chroma-Case/Chromacase into feature/adc/artist-view 2023-07-05 14:06:27 +01:00
danis
28716eeab2 init genreDetailsView 2023-07-05 09:26:45 +02:00
GitBluub
5a190f3b96 wip 2023-06-28 22:03:59 +09:00
danis
606af3901c Merge branch 'main' into feature/adc/artist-view 2023-06-28 09:22:25 +02:00
danis
b2247e79ae having a bug with api :/ 2023-06-28 09:11:49 +02:00
danis
a6ae770194 Merge branch 'main' into feature/adc/artist-view 2023-06-21 09:19:04 +02:00
danis
e378465126 components RowCustom & SongRow + artist banner 2023-06-21 08:21:34 +02:00
164 changed files with 19537 additions and 8265 deletions

View File

@@ -8,6 +8,6 @@ insert_final_newline = true
indent_style = tab
indent_size = tab
[{*.yaml,*.yml}]
[{*.yaml,*.yml,*.nix}]
indent_style = space
indent_size = 2

View File

@@ -7,6 +7,11 @@ 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
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
IGNORE_MAILS=true

5
.envrc
View File

@@ -1,4 +1 @@
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
fi
use flake
use nix

View File

@@ -93,7 +93,7 @@ jobs:
run: |
docker-compose ps -a
docker-compose logs
wget --retry-connrefused http://localhost:3000 # /healthcheck
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
- name: Run scorometer tests
run: |

3
.gitignore vendored
View File

@@ -13,3 +13,6 @@ log.html
node_modules/
./front/coverage
.venv
.data
.DS_Store
_gen

View File

@@ -1,4 +1,4 @@
#!/bin/env python3
#!/usr/bin/env python3
import sys
import os

View File

@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
COPY . .
RUN npx prisma generate
RUN npm run build
CMD npx prisma migrate dev; npm run start:prod
CMD npx prisma migrate deploy; npm run start:prod

10208
back/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start --watch --preserveWatchOutput",
"start:debug": "nest start --debug --watch --preserveWatchOutput",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
@@ -21,6 +21,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/common": "^10.1.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.1.0",
@@ -35,11 +36,15 @@
"@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",
@@ -53,6 +58,7 @@
"@types/jest": "29.5.3",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.4",
"@types/nodemailer": "^6.4.9",
"@types/passport-google-oauth20": "^2.0.11",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.1.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,15 @@
-- 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;

View File

@@ -0,0 +1,8 @@
/*
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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;

View File

@@ -4,6 +4,12 @@ generator client {
provider = "prisma-client-js"
}
generator prismaClassGenerator {
provider = "prisma-class-generator"
dryRun = false
separateRelationFields = true
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
@@ -13,7 +19,8 @@ model User {
id Int @id @default(autoincrement())
username String @unique
password String?
email String
email String? @unique
emailVerified Boolean @default(false)
googleID String? @unique
isGuest Boolean @default(false)
partyPlayed Int @default(0)
@@ -21,6 +28,16 @@ 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 {
@@ -60,6 +77,7 @@ model Song {
genre Genre? @relation(fields: [genreId], references: [id])
difficulties Json
SongHistory SongHistory[]
likedByUsers LikedSongs[]
}
model SongHistory {

View File

@@ -13,13 +13,14 @@ import {
Query,
Req,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, 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 { ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiOperation, 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')
@@ -29,6 +30,7 @@ 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({
@@ -45,6 +47,7 @@ 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 });
@@ -54,6 +57,8 @@ export class AlbumController {
}
@Get()
@ApiOkResponsePlaginated(_Album)
@ApiOperation({ description: "Get all albums paginated"})
async findAll(
@Req() req: Request,
@FilterQuery(AlbumController.filterableFields)
@@ -70,6 +75,8 @@ 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 });

View File

@@ -1,11 +1,13 @@
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();
}

View File

@@ -14,6 +14,7 @@ import { ArtistModule } from './artist/artist.module';
import { AlbumModule } from './album/album.module';
import { SearchModule } from './search/search.module';
import { HistoryModule } from './history/history.module';
import { MailerModule } from '@nestjs-modules/mailer';
@Module({
imports: [
@@ -28,6 +29,12 @@ import { HistoryModule } from './history/history.module';
SearchModule,
SettingsModule,
HistoryModule,
MailerModule.forRoot({
transport: process.env.SMTP_TRANSPORT,
defaults: {
from: process.env.MAIL_AUTHOR,
},
}),
],
controllers: [AppController],
providers: [AppService, PrismaService, ArtistService],

View File

@@ -15,14 +15,15 @@ import {
Req,
StreamableFile,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, 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 { ApiTags } from '@nestjs/swagger';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, 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')
@@ -32,6 +33,7 @@ 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);
@@ -41,6 +43,7 @@ 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 });
@@ -50,6 +53,8 @@ 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');
@@ -66,6 +71,8 @@ export class ArtistController {
}
@Get()
@ApiOperation({ description: "Get all artists paginated"})
@ApiOkResponsePlaginated(_Artist)
async findAll(
@Req() req: Request,
@FilterQuery(ArtistController.filterableFields)
@@ -82,6 +89,8 @@ 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 });

View File

@@ -7,6 +7,7 @@ import {
Body,
Delete,
BadRequestException,
ConflictException,
HttpCode,
Put,
InternalServerErrorException,
@@ -18,6 +19,8 @@ import {
HttpStatus,
ParseFilePipeBuilder,
Response,
Query,
Param,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -25,9 +28,15 @@ 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';
@@ -41,6 +50,7 @@ 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')
@@ -53,9 +63,11 @@ 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 });
@@ -67,26 +79,82 @@ 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' })
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')
@UseGuards(JwtAuthGuard)
@HttpCode(200)
@ApiOperation({description: 'Resend the verification email'})
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);
}
@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)
@Post('login')
@ApiBody({ type: LoginDto })
@ApiOperation({ description: 'Login with username and password' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
async login(@Request() req: any): Promise<JwtToken> {
return this.authService.login(req.user);
}
@HttpCode(200)
@Post('guest')
@HttpCode(200)
@ApiOperation({ description: 'Login as a guest account' })
@ApiOkResponse({ description: 'Successfully logged in', type: JwtToken })
async guest(): Promise<JwtToken> {
const user = await this.usersService.createGuest();
await this.settingsService.createUserSetting(user.id);
@@ -95,6 +163,7 @@ 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')
@@ -107,6 +176,7 @@ 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,
@@ -121,7 +191,7 @@ export class AuthController {
)
file: Express.Multer.File,
) {
const path = `/data/${req.user.id}.jpg`
const path = `/data/${req.user.id}.jpg`;
writeFile(path, file.buffer, (err) => {
if (err) throw err;
});
@@ -132,6 +202,7 @@ 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();
@@ -143,6 +214,7 @@ 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>,
@@ -168,6 +240,7 @@ 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 });
}
@@ -177,6 +250,7 @@ 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,
@@ -192,6 +266,7 @@ 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,
@@ -199,4 +274,45 @@ 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)
}
}

View File

@@ -21,7 +21,7 @@ import { GoogleStrategy } from './google.strategy';
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
signOptions: { expiresIn: '365d' },
}),
inject: [ConfigService],
}),

View File

@@ -1,13 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import PayloadInterface from './interface/payload.interface';
import { User } from 'src/models/user';
import { MailerService } from '@nestjs-modules/mailer';
@Injectable()
export class AuthService {
constructor(
private userService: UsersService,
private jwtService: JwtService,
private emailService: MailerService,
) {}
async validateUser(
@@ -31,4 +34,70 @@ export class AuthService {
access_token,
};
}
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,
},
{ expiresIn: '10h' },
);
await this.emailService.sendMail({
to: user.email,
from: 'chromacase@octohub.app',
subject: 'Mail verification for Chromacase',
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
});
}
async 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);
return false;
}
await this.userService.updateUser({
where: { id: userId },
data: { emailVerified: true },
});
return true;
}
}

View File

@@ -0,0 +1,8 @@
import { IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class PasswordResetDto {
@ApiProperty()
@IsNotEmpty()
password: string;
}

View File

@@ -14,7 +14,7 @@ import {
Req,
StreamableFile,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, Plage } from 'src/models/plage';
import { CreateGenreDto } from './dto/create-genre.dto';
import { Request } from 'express';
import { GenreService } from './genre.service';
@@ -22,6 +22,7 @@ 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')
@@ -65,6 +66,7 @@ export class GenreController {
}
@Get()
@ApiOkResponsePlaginated(_Genre)
async findAll(
@Req() req: Request,
@FilterQuery(GenreController.filterableFields)

View File

@@ -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';
}

View File

@@ -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>;
}

View File

@@ -10,21 +10,25 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, 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,
@@ -36,7 +40,9 @@ 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,
@@ -48,18 +54,24 @@ 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,
});
}
}

View File

@@ -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();
});
});

View File

@@ -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: {

View File

@@ -13,11 +13,12 @@ import {
Delete,
NotFoundException,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, 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()
@@ -48,6 +49,7 @@ export class LessonController {
summary: 'Get all lessons',
})
@Get()
@ApiOkResponsePlaginated(_Lesson)
async getAll(
@Req() request: Request,
@FilterQuery(LessonController.filterableFields)

View File

@@ -1,10 +1,56 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
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, //TODO: Data crashed with images
}),
),),
);
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
RequestLogger.buildExpressRequestLogger({
doNotLogPaths: ['/health'],
} as RequestLoggerOptions),
);
app.enableShutdownHooks();
const config = new DocumentBuilder()
@@ -12,11 +58,15 @@ async function bootstrap() {
.setDescription('The chromacase API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
const document = SwaggerModule.createDocument(app, config, {
extraModels: [...PrismaModel.extraModels],
});
SwaggerModule.setup('api', app, document);
app.useGlobalPipes(new ValidationPipe());
app.enableCors();
app.useGlobalInterceptors(new AspectLogger());
await app.listen(3000);
}
bootstrap();

View File

@@ -2,16 +2,21 @@
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
*/
import { ApiProperty } from '@nestjs/swagger';
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty, getSchemaPath } from '@nestjs/swagger';
export class Plage<T> {
export class PlageMetadata {
@ApiProperty()
metadata: {
this: string;
next: string | null;
previous: string | null;
};
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> {
@ApiProperty()
metadata: PlageMetadata;
data: T[];
constructor(data: T[], request: Request | any) {
@@ -49,3 +54,23 @@ export class Plage<T> {
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) },
},
},
},
],
},
})
)

View File

@@ -6,7 +6,7 @@ export class User {
@ApiProperty()
username: string;
@ApiProperty()
email: string;
email: string | null;
@ApiProperty()
isGuest: boolean;
@ApiProperty()

View File

@@ -12,20 +12,29 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags, ApiUnauthorizedResponse } 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();
@@ -37,6 +46,9 @@ 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);
@@ -49,6 +61,9 @@ 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);
@@ -58,4 +73,4 @@ export class SearchController {
throw new InternalServerErrorException();
}
}
}
}

View File

@@ -5,7 +5,10 @@ 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({

View File

@@ -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 {}

View File

@@ -20,10 +20,10 @@ export class SettingsService {
user: {
connect: {
id: userId,
}
}
}
})
},
},
},
});
}
async updateUserSettings(params: {
@@ -37,7 +37,9 @@ export class SettingsService {
});
}
async deleteUserSettings(where: Prisma.UserSettingsWhereUniqueInput): Promise<UserSettings> {
async deleteUserSettings(
where: Prisma.UserSettingsWhereUniqueInput,
): Promise<UserSettings> {
return this.prisma.userSettings.delete({
where,
});

View File

@@ -16,16 +16,26 @@ import {
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { ApiOkResponsePlaginated, 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 { ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiProperty, ApiResponse, ApiResponseProperty, 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')
@@ -44,6 +54,9 @@ 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');
@@ -57,6 +70,9 @@ 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');
@@ -74,6 +90,9 @@ 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');
@@ -83,6 +102,7 @@ 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({
@@ -98,6 +118,7 @@ export class SongController {
: undefined,
});
} catch {
throw new ConflictException(
await this.songService.song({ name: createSongDto.name }),
);
@@ -105,6 +126,7 @@ 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 });
@@ -114,6 +136,7 @@ export class SongController {
}
@Get()
@ApiOkResponsePlaginated(_Song)
async findAll(
@Req() req: Request,
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
@@ -129,6 +152,9 @@ 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 });
@@ -139,6 +165,8 @@ 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({

View File

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

View File

@@ -1,6 +1,6 @@
import { Controller, Get, Param, NotFoundException, Response } from '@nestjs/common';
import { Controller, Get, Post, Param, NotFoundException, Response } from '@nestjs/common';
import { UsersService } from './users.service';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { User } from 'src/models/user';
@ApiTags('users')
@@ -22,6 +22,7 @@ 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);
}

View File

@@ -2,7 +2,6 @@ import {
Injectable,
InternalServerErrorException,
NotFoundException,
StreamableFile,
} from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
@@ -13,7 +12,9 @@ import fetch from 'node-fetch';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
) {}
async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
@@ -53,7 +54,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: '',
email: null,
password: '',
},
});
@@ -89,14 +90,46 @@ 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');
const resp = await fetch(
`https://www.gravatar.com/avatar/${hash}.jpg?d=404&s=200`,
);
for (const [k, v] of resp.headers)
resp.headers.set(k, v);
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 },
}
)
}
}

View File

@@ -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 400
Integer response status 409
Login user-duplicate
[Teardown] DELETE /auth/me

37
config/logs_nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
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;
}
}
}

36
config/loki-config.yaml Normal file
View File

@@ -0,0 +1,36 @@
---
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

View File

@@ -0,0 +1,22 @@
---
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'

View File

@@ -1,3 +1,10 @@
networks:
loki:
volumes:
scoro_logs:
services:
back:
build:
@@ -25,6 +32,9 @@ services:
volumes:
- ./scorometer:/app
- ./assets:/assets
- scoro_logs:/logs
networks:
- loki
db:
container_name: db
@@ -40,13 +50,14 @@ services:
retries: 5
ports:
- "5432:5432"
front:
build:
context: ./front
dockerfile: Dockerfile.dev
environment:
- SCOROMETER_URL=http://scorometer:6543/
- NGINX_PORT=80
- NGINX_PORT=4567
ports:
- "19006:19006"
volumes:
@@ -55,3 +66,20 @@ 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
- scorometer
volumes:
- "./front/assets:/assets:ro"
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
ports:
- "4567:4567"

185
docker-compose.log.yml Normal file
View File

@@ -0,0 +1,185 @@
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

View File

@@ -1,3 +1,9 @@
networks:
loki:
volumes:
scoro_logs:
services:
back:
image: ghcr.io/chroma-case/back:main
@@ -16,13 +22,14 @@ 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_PASSWORD}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORDgrafana}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- "5432:5432"
@@ -43,4 +50,4 @@ services:
depends_on:
- "back"
env_file:
- .env
- .env

View File

@@ -1,3 +1,12 @@
networks:
loki:
volumes:
db:
scoro_logs:
services:
back:
build: ./back
@@ -17,6 +26,7 @@ services:
- "6543:6543"
volumes:
- ./assets:/assets
- scoro_logs:/logs
db:
container_name: db
image: postgres:alpine3.14
@@ -35,11 +45,7 @@ services:
retries: 5
front:
build:
context: ./front
args:
- API_URL=${API_URL}
- SCORO_URL=${SCORO_URL}
build: ./front
environment:
- API_URL=http://back:3000/
- SCOROMETER_URL=http://scorometer:6543/
@@ -49,7 +55,4 @@ services:
depends_on:
- "back"
env_file:
- .env
volumes:
db:
- .env

43
flake.lock generated
View File

@@ -1,43 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1665573177,
"narHash": "sha256-Arkrf3zmi3lXYpbSe9H+HQxswQ6jxsAmeQVq5Sr/OZc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d2afb051ffd904af5a825f58abee3c63b148c5f2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,31 +0,0 @@
{
description = "A prisma test project";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
nativeBuildInputs = [ pkgs.bashInteractive ];
buildInputs = with pkgs; [
nodePackages.prisma
nodePackages."@nestjs/cli"
nodePackages.npm
nodejs-slim
yarn
python3
pkg-config
];
shellHook = with pkgs; ''
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
'';
};
});
}

View File

@@ -1,4 +1,7 @@
node_modules/
.expo/
.idea/
.vscode/
.vscode/
.dockerignore
Dockerfile
Dockerfile.dev

2
front/.gitignore vendored
View File

@@ -14,5 +14,7 @@ yarn.error*
# macOS
.DS_Store
yarn-error.log
.idea/
.expo

View File

@@ -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,8 +21,9 @@ import { PlageHandler } from './models/Plage';
import { ListHandler } from './models/List';
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
import * as yup from 'yup';
import { base64ToBlob } from 'file64';
import { base64ToBlob } from './utils/base64ToBlob';
import { ImagePickerAsset } from 'expo-image-picker';
import Constant from 'expo-constants';
type AuthenticationInput = { username: string; password: string };
type RegistrationInput = AuthenticationInput & { email: string };
@@ -68,7 +69,7 @@ export default class API {
public static readonly baseUrl =
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
? '/api'
: Constants.manifest?.extra?.apiUrl;
: Constant.manifest?.extra?.apiUrl;
public static async fetch(
params: FetchParams,
handle: Pick<Required<HandleParams>, 'raw'>
@@ -97,8 +98,16 @@ export default class API {
});
if (!handle || handle.emptyResponse) {
if (!response.ok) {
console.log(await response.json());
throw new APIError(response.statusText, response.status);
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');
}
return;
}
@@ -110,7 +119,7 @@ export default class API {
try {
const jsonResponse = JSON.parse(body);
if (!response.ok) {
throw new APIError(response.statusText ?? body, response.status);
throw new APIError(response.statusText ?? body, response.status, 'unknownError');
}
const validated = await handler.validator.validate(jsonResponse).catch((e) => {
if (e instanceof yup.ValidationError) {
@@ -287,6 +296,43 @@ 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
@@ -322,6 +368,23 @@ 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
@@ -608,4 +671,31 @@ 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) }
),
};
}
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import store, { persistor } from './state/Store';
@@ -10,12 +10,22 @@ 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();
setTimeout(SplashScreen.hideAsync, 500);
const [fontsLoaded] = useFonts({
Lexend: require('./assets/fonts/lexend.ttf'),
});
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
return (
<Provider store={store}>

View File

@@ -1,10 +1,6 @@
# a Dockerfile to build the expo web app and serve it with nginx
#
# Build the app
FROM node:16-alpine as build
WORKDIR /app
# install expo cli
RUN yarn global add expo-cli@6.0.5
# add sharp-cli (^2.1.0) for faster image processing
RUN yarn global add sharp-cli@^2.1.0
@@ -22,6 +18,5 @@ 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 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;'
COPY ./assets/ /usr/share/nginx/html/assets/
COPY nginx.conf.template /etc/nginx/templates/default.conf.template

View File

@@ -11,7 +11,6 @@ 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';
@@ -28,7 +27,14 @@ 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 = () => '';
@@ -40,6 +46,11 @@ 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,
@@ -59,6 +70,11 @@ const protectedRoutes = () =>
options: { title: translate('artistFilter') },
link: '/artist/:artistId',
},
Genre: {
component: GenreDetailsView,
options: { title: translate('genreFilter') },
link: '/genre/:genreId',
},
Score: {
component: ScoreView,
options: { title: translate('score'), headerLeft: null },
@@ -75,6 +91,11 @@ const protectedRoutes = () =>
link: undefined,
},
User: { component: ProfileView, options: { title: translate('user') }, link: '/user' },
Verified: {
component: VerifiedView,
options: { title: 'Verify email', headerShown: false },
link: '/verify',
},
} as const);
const publicRoutes = () =>
@@ -85,15 +106,13 @@ const publicRoutes = () =>
link: '/',
},
Login: {
component: (params: RouteProps<{}>) =>
AuthenticationView({ isSignup: false, ...params }),
options: { title: translate('signInBtn') },
component: SigninView,
options: { title: translate('signInBtn'), headerShown: false },
link: '/login',
},
Signup: {
component: (params: RouteProps<{}>) =>
AuthenticationView({ isSignup: true, ...params }),
options: { title: translate('signUpBtn') },
component: SignupView,
options: { title: translate('signUpBtn'), headerShown: false },
link: '/signup',
},
Oops: {
@@ -106,6 +125,16 @@ 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

View File

@@ -12,63 +12,118 @@ const ThemeProvider = ({ children }: { children: JSX.Element }) => {
useSystemColorMode: false,
initialColorMode: colorScheme,
},
fonts: {
heading: 'Lexend',
body: 'Lexend',
mono: 'Lexend',
},
colors: {
primary: {
50: '#e6faea',
100: '#c8e7d0',
200: '#a7d6b5',
300: '#86c498',
400: '#65b47c',
500: '#4b9a62',
600: '#3a784b',
700: '#275635',
800: '#14341f',
900: '#001405',
50: '#eff1fe',
100: '#e7eafe',
200: '#cdd4fd',
300: '#5f74f7',
400: '#5668de',
500: '#4c5dc6',
600: '#4757b9',
700: '#394694',
800: '#2b346f',
900: '#212956',
},
secondary: {
50: '#d8ffff',
100: '#acffff',
200: '#7dffff',
300: '#4dffff',
400: '#28ffff',
500: '#18e5e6',
600: '#00b2b3',
700: '#007f80',
800: '#004d4e',
900: '#001b1d',
50: '#f7f3ff',
100: '#f3edfe',
200: '#e6d9fe',
300: '#ae84fb',
400: '#9d77e2',
500: '#8b6ac9',
600: '#8363bc',
700: '#684f97',
800: '#4e3b71',
900: '#3d2e58',
},
error: {
50: '#ffe2e9',
100: '#ffb1bf',
200: '#ff7f97',
300: '#ff4d6d',
400: '#fe1d43',
500: '#e5062b',
600: '#b30020',
700: '#810017',
800: '#4f000c',
900: '#200004',
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',
},
notification: {
50: '#ffe1e1',
100: '#ffb1b1',
200: '#ff7f7f',
300: '#ff4c4c',
400: '#ff1a1a',
500: '#e60000',
600: '#b40000',
700: '#810000',
800: '#500000',
900: '#210000',
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',
},
},
components: {
Button: {
variants: {
solid: () => ({
rounded: 'full',
}),
baseStyle: () => ({
borderRadius: 'md',
}),
},
Link: {
defaultProps: {
isUnderlined: false,
},
baseStyle: () => ({
_text: {
color: 'secondary.300',
},
_hover: {
isUnderlined: true,
_text: {
color: 'secondary.400',
},
},
}),
},
},
})}

View File

@@ -6,7 +6,7 @@ module.exports = {
icon: './assets/icon.png',
userInterfaceStyle: 'light',
splash: {
image: './assets/splashLogo.png',
image: './assets/splash.png',
resizeMode: 'contain',
backgroundColor: '#ffffff',
},
@@ -18,12 +18,6 @@ module.exports = {
supportsTablet: true,
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#FFFFFF',
package: 'com.chromacase.chromacase',
versionCode: 1,
},
package: 'build.apk',
},
web: {

View File

@@ -7,7 +7,7 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splashLogo.png",
"image": "./assets/splash.png",
"resizeMode": "cover",
"backgroundColor": "#ffffff"
},
@@ -19,10 +19,6 @@
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#FFFFFF"
},
"package": "build.apk"
},
"web": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

BIN
front/assets/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Binary file not shown.

BIN
front/assets/full_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

BIN
front/assets/full_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

BIN
front/assets/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 65 KiB

BIN
front/assets/icon_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
front/assets/icon_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
front/assets/metronome.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

BIN
front/assets/title_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

View File

@@ -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.

View File

@@ -0,0 +1,85 @@
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;

View File

@@ -1,10 +1,13 @@
import React from 'react';
import React, { useState } from 'react';
import { ElementProps } from './ElementTypes';
import { RawElement } from './RawElement';
import { Pressable, IPressableProps } from 'native-base';
import { View, Column } from 'native-base';
import { StyleSheet } from 'react-native';
import InteractiveBase from '../UI/InteractiveBase';
export const Element = <T extends ElementProps>(props: T) => {
let actionFunction: IPressableProps['onPress'] = null;
let actionFunction: (() => void) | null | undefined = null;
const [dropdownValue, setDropdownValue] = useState(false);
switch (props.type) {
case 'text':
@@ -13,18 +16,81 @@ 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 (
<Pressable onPress={actionFunction}>
{({ isHovered }) => {
return <RawElement element={props} isHovered={isHovered} />;
}}
</Pressable>
<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>
);
}
return <RawElement element={props} />;
return (
<View style={{ backgroundColor: 'rgba(16, 16, 20, 0.50)' }}>
<RawElement element={props} />
</View>
);
};

View File

@@ -1,9 +1,7 @@
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 = {
@@ -12,13 +10,12 @@ type ElementListProps = {
};
const ElementList = ({ elements, style }: ElementListProps) => {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const elementStyle = {
borderRadius: 10,
boxShadow: isDark
? '0px 0px 3px 0px rgba(255,255,255,0.6)'
: '0px 0px 3px 0px rgba(0,0,0,0.4)',
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
backgroundColor: 'transparent',
overflow: 'hidden',
} as const;
@@ -27,7 +24,7 @@ const ElementList = ({ elements, style }: ElementListProps) => {
{elements.map((element, index) => (
<Box key={element.title}>
<Element {...element} />
{index < elements.length - 1 && <Divider />}
{index < elements.length - 1 && <Divider bg="transparent" thickness="2" />}
</Box>
))}
</Column>

View File

@@ -1,5 +1,6 @@
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;
@@ -12,6 +13,7 @@ export type ElementProps = {
| { type: 'toggle'; data: ElementToggleProps }
| { type: 'dropdown'; data: ElementDropdownProps }
| { type: 'range'; data: ElementRangeProps }
| { type: 'sectionDropdown'; data: SectionDropdownProps }
| { type: 'custom'; data: React.ReactNode }
);
@@ -31,6 +33,11 @@ export type ElementToggleProps = {
defaultValue?: boolean;
};
export type SectionDropdownProps = {
value: boolean;
section: React.ReactNode[];
};
export type ElementDropdownProps = {
options: DropdownOption[];
onSelect: (value: string) => void;
@@ -93,13 +100,16 @@ 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} />
@@ -113,6 +123,7 @@ 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
@@ -126,7 +137,7 @@ export const getElementRangeNode = (
isDisabled={disabled}
onChangeEnd={onChange}
accessibilityLabel={`Slider for ${title}`}
width="200"
width={layout.width > 650 ? '200' : '100'}
>
<Slider.Track>
<Slider.FilledTrack />

View File

@@ -1,6 +1,5 @@
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 {
@@ -9,118 +8,124 @@ import {
getElementToggleNode,
getElementRangeNode,
} from './ElementTypes';
import { ArrowDown2 } from 'iconsax-react-native';
type RawElementProps = {
element: ElementProps;
isHovered?: boolean;
};
export const RawElement = ({ element, isHovered }: RawElementProps) => {
export const RawElement = ({ element }: 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 (
<Row
<Column
style={{
width: '100%',
height: 45,
padding: 15,
paddingVertical: 10,
paddingHorizontal: 20,
justifyContent: 'space-between',
alignContent: 'stretch',
alignItems: 'center',
backgroundColor: isHovered
? isDark
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.05)'
: undefined,
}}
>
<Box
<Row
style={{
flexGrow: 1,
flexShrink: 1,
opacity: disabled ? 0.6 : 1,
width: '100%',
height: 45,
justifyContent: 'space-between',
alignContent: 'stretch',
alignItems: 'center',
}}
>
{icon}
<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
<Box
style={{
alignItems: 'center',
marginRight: 3,
flexGrow: 1,
flexShrink: 1,
opacity: disabled ? 0.6 : 1,
paddingLeft: icon ? 16 : 0,
}}
>
{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}`}
<Column maxW={'90%'}>
<Text isTruncated maxW={'100%'}>
{title}
</Text>
{description && (
<Text
isTruncated
maxW={'100%'}
style={{
maxWidth: isSmallScreen ? '90vw' : '20vw',
opacity: 0.6,
fontSize: 10,
}}
>
<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>
{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>
);
};

View File

@@ -0,0 +1,37 @@
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>
);
};

View File

@@ -1,17 +1,16 @@
import * as React from 'react';
import PartitionView from './PartitionView';
import PhaserCanvas from './PartitionVisualizer/PhaserCanvas';
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
import { PianoCursorPosition } from '../models/PianoGame';
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 = ({
@@ -20,10 +19,10 @@ const PartitionCoord = ({
onEndReached,
onPause,
onResume,
timestamp,
bpmRef,
}: PartitionCoordProps) => {
const [partitionData, setPartitionData] = React.useState<
[string, PianoCursorPosition[]] | null
[[number, number], string, PianoCursorPosition[]] | null
>(null);
return (
@@ -31,21 +30,22 @@ const PartitionCoord = ({
{!partitionData && (
<PartitionView
file={file}
onPartitionReady={(base64data, a) => {
setPartitionData([base64data, a]);
bpmRef={bpmRef}
onPartitionReady={(dims, base64data, a) => {
setPartitionData([dims, base64data, a]);
onPartitionReady();
}}
onEndReached={() => {
console.log('osmd end reached');
}}
timestamp={timestamp}
timestamp={0}
/>
)}
{partitionData && (
<PhaserCanvas
partitionB64={partitionData?.[0]}
cursorPositions={partitionData?.[1]}
timestamp={timestamp}
partitionDims={partitionData?.[0]}
partitionB64={partitionData?.[1]}
cursorPositions={partitionData?.[2]}
onPause={onPause}
onResume={onResume}
onEndReached={() => {

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-mixed-spaces-and-tabs */
// Inspired from OSMD example project
// https://github.com/opensheetmusicdisplay/react-opensheetmusicdisplay/blob/master/src/lib/OpenSheetMusicDisplay.jsx
import React, { useEffect } from 'react';
import React, { MutableRefObject, useEffect } from 'react';
import {
CursorType,
Fraction,
@@ -10,12 +10,16 @@ import {
Note,
} from 'opensheetmusicdisplay';
import useColorScheme from '../hooks/colorScheme';
import { PianoCursorPosition } from './PartitionVisualizer/PhaserCanvas';
import { PianoCursorPosition } from '../models/PianoGame';
type PartitionViewProps = {
// The Buffer of the MusicXML file retreived from the API
file: string;
onPartitionReady: (base64data: string, cursorInfos: PianoCursorPosition[]) => void;
onPartitionReady: (
dims: [number, number],
base64data: string,
cursorInfos: PianoCursorPosition[]
) => void;
bpmRef: MutableRefObject<number>;
onEndReached: () => void;
// Timestamp of the play session, in milisecond
timestamp: number;
@@ -59,6 +63,7 @@ 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) {
@@ -97,7 +102,6 @@ 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);
@@ -110,12 +114,18 @@ 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,
x: pos.offset * scale,
timing: pos.sNinfos.sNL,
timestamp: pos.sNinfos.ts,
notes: pos.notes,

View File

@@ -1,22 +1,31 @@
// create a simple phaser effect with a canvas that can be easily imported as a react component
import * as React from 'react';
import { useEffect } from 'react';
import { useEffect, useContext } from 'react';
import { Dimensions } from 'react-native';
import Phaser from 'phaser';
import useColorScheme from '../../hooks/colorScheme';
import { RootState, useSelector } from '../../state/Store';
import { setSoundPlayer as setSPStore } from '../../state/SoundPlayerSlice';
import { useDispatch } from 'react-redux';
import { SplendidGrandPiano, CacheStorage } from 'smplr';
import { Note } from 'opensheetmusicdisplay';
import { handlePianoGameMsg } from './PianoGameUpdateFunctions';
import { PianoCC } from '../../views/PlayView';
import { PianoCanvasMsg, PianoCursorNote, PianoCursorPosition } from '../../models/PianoGame';
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)) {
@@ -48,17 +57,42 @@ 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);
this.cursor = this.add.rectangle(0, 0, 30, 350, 0x31ef8c, 0.5).setOrigin(0, 0);
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.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,
});
});
}
@@ -76,6 +110,16 @@ 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) {
@@ -100,47 +144,30 @@ 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 = timestamp;
globalTimestamp = pianoCC.timestamp;
globalPressedKeys = pianoCC.pressedKeys;
globalMessages = pianoCC.messages;
useEffect(() => {
if (isValidSoundPlayer(soundPlayer)) {
@@ -164,13 +191,43 @@ 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: 1000,
height: 400,
scene: pianoScene,
width: max(width * 0.9, 850),
height: min(max(height * 0.7, 400), partitionDims[1]),
scene: [pianoScene, UIScene],
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_HORIZONTALLY,

View File

@@ -0,0 +1,60 @@
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!);
}
}
};

View File

@@ -0,0 +1,34 @@
import { useColorScheme } from 'react-native';
import { RootState, useSelector } from '../state/Store';
import { Box, Pressable } from 'native-base';
const RowCustom = (props: Parameters<typeof Box>[0] & { onPress?: () => void }) => {
const settings = useSelector((state: RootState) => state.settings.local);
const systemColorMode = useColorScheme();
const colorScheme = settings.colorScheme;
return (
<Pressable onPress={props.onPress}>
{({ isHovered, isPressed }) => (
<Box
{...props}
py={3}
my={1}
bg={
(colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark'
? isHovered || isPressed
? 'gray.800'
: undefined
: isHovered || isPressed
? 'coolGray.200'
: undefined
}
>
{props.children}
</Box>
)}
</Pressable>
);
};
export default RowCustom;

View File

@@ -1,8 +1,20 @@
import { Box, useBreakpointValue, useTheme } from 'native-base';
import {
Box,
Column,
Row,
Select,
useBreakpointValue,
useTheme,
Text,
View,
Wrap,
} 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
@@ -10,68 +22,210 @@ type ScoreGraphProps = {
};
const formatScoreDate = (playDate: Date): string => {
const pad = (n: number) => n.toString().padStart(2, '0');
const formattedDate = `${pad(playDate.getDay())}/${pad(playDate.getMonth())}`;
const formattedTime = `${pad(playDate.getHours())}:${pad(playDate.getMinutes())}`;
return `${formattedDate} ${formattedTime}`;
// const formattedDate = `${pad(playDate.getDay())}/${pad(playDate.getMonth())}`;
// const formattedTime = `${pad(playDate.getHours())}:${pad(playDate.getMinutes())}`;
return `${playDate.getDate()}/${playDate.getMonth()}`;
};
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 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);
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,
});
}
}
return (
<Box
bgColor={theme.colors.primary[500]}
style={{ width: '100%', borderRadius: CardBorderRadius }}
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
>
<LineChart
data={{
labels: isSmall ? [] : scores.map(({ playDate }) => formatScoreDate(playDate)),
datasets: [
{
data: scores.map(({ score }) => score),
},
],
}}
width={containerWidth}
height={200} // Completely arbitrary
transparent={true}
yAxisSuffix=" pts"
chartConfig={{
decimalPlaces: 0,
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
labelColor: () => theme.colors.white,
propsForDots: {
r: '6',
strokeWidth: '2',
},
}}
bezier
<Column>
<Row
style={{
margin: 3,
shadowColor: theme.colors.primary[400],
shadowOpacity: 1,
shadowRadius: 20,
borderRadius: CardBorderRadius,
alignItems: 'center',
}}
/>
</Box>
>
<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>
);
};

View File

@@ -5,7 +5,7 @@ import { translate } from '../i18n/i18n';
import { SearchContext } from '../views/SearchView';
import { debounce } from 'lodash';
export type Filter = 'artist' | 'song' | 'genre' | 'all';
export type Filter = 'artist' | 'song' | 'genre' | 'all' | 'favorites';
type FilterButton = {
name: string;
@@ -42,6 +42,11 @@ const SearchBar = () => {
callback: () => updateFilter('all'),
id: 'all',
},
{
name: translate('favoriteFilter'),
callback: () => updateFilter('favorites'),
id: 'favorites',
},
{
name: translate('artistFilter'),
callback: () => updateFilter('artist'),

View File

@@ -1,34 +1,32 @@
import React, { useMemo } from 'react';
import {
HStack,
VStack,
Heading,
Text,
Pressable,
Box,
Card,
Image,
Flex,
useBreakpointValue,
Column,
ScrollView,
} from 'native-base';
import { SafeAreaView, useColorScheme } from 'react-native';
import { RootState, useSelector } from '../state/Store';
import { SafeAreaView } from 'react-native';
import { SearchContext } from '../views/SearchView';
import { useQueries, useQuery } from '../Queries';
import { translate } from '../i18n/i18n';
import API from '../API';
import LoadingComponent from './Loading';
import LoadingComponent, { LoadingView } 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,
@@ -37,101 +35,6 @@ const swaToSongCardProps = (song: SongWithArtist) => ({
cover: song.cover ?? 'https://picsum.photos/200',
});
const RowCustom = (props: Parameters<typeof Box>[0] & { onPress?: () => void }) => {
const settings = useSelector((state: RootState) => state.settings.local);
const systemColorMode = useColorScheme();
const colorScheme = settings.colorScheme;
return (
<Pressable onPress={props.onPress}>
{({ isHovered, isPressed }) => (
<Box
{...props}
py={3}
my={1}
bg={
(colorScheme == 'system' ? systemColorMode : colorScheme) == 'dark'
? isHovered || isPressed
? 'gray.800'
: undefined
: isHovered || isPressed
? 'coolGray.200'
: undefined
}
>
{props.children}
</Box>
)}
</Pressable>
);
};
type SongRowProps = {
song: Song | SongWithArtist; // TODO: remove Song
onPress: () => void;
};
const SongRow = ({ song, onPress }: SongRowProps) => {
return (
<RowCustom width={'100%'}>
<HStack px={2} space={5} justifyContent={'space-between'}>
<Image
flexShrink={0}
flexGrow={0}
pl={10}
style={{ zIndex: 0, aspectRatio: 1, borderRadius: 5 }}
source={{ uri: song.cover }}
alt={song.name}
/>
<HStack
style={{
display: 'flex',
flexShrink: 1,
flexGrow: 1,
alignItems: 'center',
justifyContent: 'flex-start',
}}
space={6}
>
<Text
style={{
flexShrink: 1,
}}
isTruncated
pl={10}
maxW={'100%'}
bold
fontSize="md"
>
{song.name}
</Text>
<Text
style={{
flexShrink: 0,
}}
fontSize={'sm'}
>
{song.artistId ?? 'artist'}
</Text>
</HStack>
<TextButton
flexShrink={0}
flexGrow={0}
translate={{ translationKey: 'playBtn' }}
colorScheme="primary"
variant={'outline'}
size="sm"
onPress={onPress}
/>
</HStack>
</RowCustom>
);
};
SongRow.defaultProps = {
onPress: () => {},
};
const HomeSearchComponent = () => {
const { updateStringQuery } = React.useContext(SearchContext);
const { isLoading: isLoadingHistory, data: historyData = [] } = useQuery(
@@ -209,8 +112,14 @@ type SongsSearchComponentProps = {
};
const SongsSearchComponent = (props: SongsSearchComponentProps) => {
const { songData } = React.useContext(SearchContext);
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);
};
return (
<ScrollView>
@@ -223,6 +132,12 @@ 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 });
@@ -252,15 +167,17 @@ const ArtistSearchComponent = (props: ItemSearchComponentProps) => {
</Text>
{artistData?.length ? (
<CardGridCustom
content={artistData.slice(0, props.maxItems ?? artistData.length).map((a) => ({
image: API.getArtistIllustration(a.id),
name: a.name,
id: a.id,
onPress: () => {
API.createSearchHistoryEntry(a.name, 'artist');
navigation.navigate('Artist', { artistId: a.id });
},
}))}
content={artistData
.slice(0, props.maxItems ?? artistData.length)
.map((artistData) => ({
image: API.getArtistIllustration(artistData.id),
name: artistData.name,
id: artistData.id,
onPress: () => {
API.createSearchHistoryEntry(artistData.name, 'artist');
navigation.navigate('Artist', { artistId: artistData.id });
},
}))}
cardComponent={ArtistCard}
/>
) : (
@@ -287,7 +204,7 @@ const GenreSearchComponent = (props: ItemSearchComponentProps) => {
id: g.id,
onPress: () => {
API.createSearchHistoryEntry(g.name, 'genre');
navigation.navigate('Home');
navigation.navigate('Genre', { genreId: g.id });
},
}))}
cardComponent={GenreCard}
@@ -299,6 +216,52 @@ 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';
@@ -344,6 +307,8 @@ const FilterSwitch = () => {
return <ArtistSearchComponent />;
case 'genre':
return <GenreSearchComponent />;
case 'favorites':
return <FavoritesComponent />;
default:
return <Text>Something very bad happened: {currentFilter}</Text>;
}
@@ -351,7 +316,8 @@ const FilterSwitch = () => {
export const SearchResultComponent = () => {
const { stringQuery } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim();
const { filter } = React.useContext(SearchContext);
const shouldOutput = !!stringQuery.trim() || filter == 'favorites';
return shouldOutput ? (
<Box p={5}>

View File

@@ -0,0 +1,86 @@
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;

View File

@@ -0,0 +1,147 @@
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;

View File

@@ -0,0 +1,82 @@
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;

View File

@@ -0,0 +1,261 @@
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;

View File

@@ -0,0 +1,23 @@
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;

View File

@@ -0,0 +1,113 @@
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;

Some files were not shown because too many files have changed in this diff Show More