Compare commits
176 Commits
redesign
...
feat/crawl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd4a8acec | ||
|
|
e63789cbc1 | ||
|
|
4a4f9e2a55 | ||
| 3860c9f72a | |||
| b02b23a978 | |||
| 5b0c1f8992 | |||
|
|
8155549031 | ||
|
|
1ca4633360 | ||
|
|
bb304fa8cd | ||
|
|
9a1f1f78cb | ||
|
|
96bb830600 | ||
|
|
1333b74001 | ||
|
|
ece87dbdb9 | ||
|
|
e82a6b1dd6 | ||
|
|
cd2e119dc6 | ||
|
|
c9928f1cce | ||
|
|
7aac3922d6 | ||
|
|
82403c811e | ||
|
|
230c60bcd0 | ||
|
|
177e903b07 | ||
|
|
a11c236753 | ||
|
|
29ef585410 | ||
|
|
f8be2c2462 | ||
|
|
7d27af1e2d | ||
|
|
258fe91ae7 | ||
|
|
711b5d583b | ||
|
|
4416808056 | ||
|
|
979c27c087 | ||
|
|
b3117886cf | ||
|
|
1c248fa479 | ||
|
|
ec62f4b085 | ||
|
|
04bad30aaa | ||
|
|
e5a52d0f94 | ||
|
|
68c6c6fa11 | ||
|
|
94a64d16e6 | ||
| 7aa7f50ecb | |||
| ee8e0e26db | |||
| 31b965e8f6 | |||
| 94658d4379 | |||
|
|
49a735631a | ||
|
|
1905daec60 | ||
|
|
7a1f4fb787 | ||
|
|
f3cdba34fb | ||
|
|
5b7cb6746d | ||
|
|
6e3e73982f | ||
|
|
8e5c65e6f2 | ||
|
|
94875d4c7f | ||
|
|
e817021ede | ||
|
|
dcca1b1f1c | ||
|
|
c0c2918e72 | ||
|
|
973f9bf5b3 | ||
|
|
162fc9148f | ||
|
|
57d646f6eb | ||
|
|
6768b0b2a6 | ||
|
|
fa14d1f979 | ||
|
|
c4ca2e509e | ||
|
|
1abfbf391f | ||
|
|
073ff033f3 | ||
|
|
23e5941700 | ||
|
|
027d450579 | ||
|
|
ad9bbbc2b9 | ||
|
|
58af78b1d3 | ||
|
|
09d2da8eec | ||
|
|
8abaaf6624 | ||
|
|
3c3697be61 | ||
|
|
073c00a35e | ||
|
|
58d761c359 | ||
|
|
aaaf73f632 | ||
|
|
f83043a9c9 | ||
|
|
cea6d8d0bc | ||
|
|
607c35b621 | ||
|
|
13d0be4586 | ||
|
|
3e1e41f117 | ||
|
|
8f9d7e4a85 | ||
|
|
1e504c8982 | ||
|
|
e56436db3a | ||
|
|
bc227fb0ea | ||
|
|
49bc4f9f45 | ||
|
|
73076c4b28 | ||
|
|
8732972b3f | ||
|
|
cd9d64e501 | ||
|
|
62bf7ec035 | ||
|
|
659f5d5d84 | ||
|
|
bbc53f04de | ||
|
|
431427d7ad | ||
|
|
611ab57c5d | ||
| bc13c10f1a | |||
| 91c9e2b295 | |||
| 585be2aa19 | |||
| 654022b48a | |||
| afab03baf8 | |||
| a52c10fc2c | |||
| f2ed598865 | |||
| 02fc8175f4 | |||
|
|
628e50a48d | ||
|
|
70ab56ce3a | ||
|
|
1fefe7912d | ||
|
|
c21f5f0659 | ||
|
|
46ef0a7f1b | ||
|
|
b43c64962a | ||
|
|
64640eda55 | ||
|
|
a6d9cb3b40 | ||
|
|
b61541f7b8 | ||
|
|
3ff523560b | ||
|
|
b61968706d | ||
|
|
2f27278d3a | ||
|
|
e1ab9fe118 | ||
|
|
b1d0415ba0 | ||
|
|
8ab85ab689 | ||
|
|
16cd794e3b | ||
|
|
f85c30a53b | ||
|
|
6da96ed886 | ||
|
|
852fbd5c87 | ||
|
|
5cec62d1b1 | ||
|
|
7e866f9826 | ||
|
|
2f50f694f3 | ||
|
|
e0f2674811 | ||
|
|
b84ee11f45 | ||
|
|
a2494ce498 | ||
|
|
b76d496034 | ||
|
|
a81d3ee34d | ||
|
|
85473ae492 | ||
|
|
9655e986ff | ||
|
|
101ea8498b | ||
|
|
7d33f85cbc | ||
|
|
66d792715e | ||
|
|
40581f4a45 | ||
|
|
2ca3fcb81a | ||
|
|
30fcacbec6 | ||
|
|
7c3289ccec | ||
|
|
7438986bcd | ||
|
|
3ac017a5f0 | ||
|
|
8e5cc1bc44 | ||
|
|
125a7faf02 | ||
|
|
c9d3ef88e7 | ||
|
|
0ba3bec5aa | ||
|
|
539c35c903 | ||
|
|
e1463d41b9 | ||
|
|
01394056a6 | ||
|
|
1396fcb39c | ||
|
|
c81f8df61c | ||
|
|
1255343b97 | ||
|
|
f7562c18bd | ||
|
|
a3676fabf8 | ||
|
|
dc398d6e06 | ||
|
|
d5da112a01 | ||
|
|
96048bd671 | ||
|
|
dcdc6b196d | ||
|
|
9f542fc9dd | ||
| 930191569f | |||
| 74cd9c0df2 | |||
| d2642b4fb8 | |||
| ebcc48cc57 | |||
| 95b08935cc | |||
| 04487c9b24 | |||
|
|
20eb62d19b | ||
|
|
567d3250e2 | ||
| 4207d5ee50 | |||
|
|
2ec95dd3c3 | ||
|
|
c0d9ee7ca6 | ||
|
|
bf09a25eb5 | ||
|
|
373128ba53 | ||
|
|
3a09d10d3b | ||
|
|
87de52cae0 | ||
|
|
931fe13eee | ||
|
|
28716eeab2 | ||
|
|
27f7945289 | ||
|
|
5a190f3b96 | ||
|
|
606af3901c | ||
|
|
b2247e79ae | ||
|
|
3d76834f45 | ||
| ccc86895e2 | |||
| 279d16d59a | |||
| 04d288b844 | |||
|
|
a6ae770194 | ||
|
|
e378465126 |
@@ -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
|
||||
|
||||
@@ -7,4 +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
@@ -1,4 +1 @@
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
|
||||
fi
|
||||
use flake
|
||||
use nix
|
||||
|
||||
16
.github/workflows/CI.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
|
||||
- name: Type Check
|
||||
run: yarn tsc
|
||||
- name: Check Prettier
|
||||
@@ -84,16 +84,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Copy env file to github secret env file
|
||||
run: |
|
||||
touch .env
|
||||
echo "POSTGRES_USER=user" >> .env
|
||||
echo "POSTGRES_PASSWORD=eip" >> .env
|
||||
echo "POSTGRES_NAME=chromacase" >> .env
|
||||
echo "POSTGRES_HOST=db" >> .env
|
||||
echo "DATABASE_URL=postgresql://user:eip@db:5432/chromacase" >> .env
|
||||
echo "JWT_SECRET=wow" >> .env
|
||||
echo "POSTGRES_DB=chromacase" >> .env
|
||||
echo "API_URL=http://localhost:80/api" >> .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Start the service
|
||||
run: docker-compose up -d back db
|
||||
@@ -101,7 +92,8 @@ jobs:
|
||||
- name: Perform healthchecks
|
||||
run: |
|
||||
docker-compose ps -a
|
||||
wget --retry-connrefused http://localhost:3000 # /healthcheck
|
||||
docker-compose logs
|
||||
wget --retry-connrefused http://localhost:3000 || (docker-compose logs && exit 1)
|
||||
|
||||
- name: Run scorometer tests
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
@@ -13,3 +13,6 @@ log.html
|
||||
node_modules/
|
||||
./front/coverage
|
||||
.venv
|
||||
.data
|
||||
.DS_Store
|
||||
_gen
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/env python3
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
@@ -5,4 +5,4 @@ RUN npm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
CMD npx prisma migrate dev; npm run start:prod
|
||||
CMD npx prisma migrate deploy; npm run start:prod
|
||||
|
||||
19482
back/package-lock.json
generated
@@ -10,8 +10,8 @@
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"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,51 +21,61 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.0.0",
|
||||
"@nestjs/config": "^2.1.0",
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/jwt": "^8.0.1",
|
||||
"@nestjs-modules/mailer": "^1.9.1",
|
||||
"@nestjs/common": "^10.1.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.1.0",
|
||||
"@nestjs/jwt": "^10.1.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/passport": "^8.2.2",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/swagger": "^5.2.1",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.1.0",
|
||||
"@nestjs/swagger": "^7.1.2",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/passport": "^1.0.9",
|
||||
"@types/passport": "^1.0.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"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": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"swagger-ui-express": "^4.5.0"
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.0.0",
|
||||
"@nestjs/schematics": "^8.0.0",
|
||||
"@nestjs/testing": "^8.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.5",
|
||||
"prettier": "^2.3.2",
|
||||
"prisma": "^4.4.0",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.3.5"
|
||||
"@nestjs/cli": "^10.1.10",
|
||||
"@nestjs/schematics": "^10.0.1",
|
||||
"@nestjs/testing": "^10.1.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.4.4",
|
||||
"@types/nodemailer": "^6.4.9",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
||||
"@typescript-eslint/parser": "^6.1.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.6.1",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.4.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
12
back/prisma/migrations/20230621090510_google/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[googleID]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "googleID" TEXT,
|
||||
ALTER COLUMN "password" DROP NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_googleID_key" ON "User"("googleID");
|
||||
2
back/prisma/migrations/20230907141258_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
8
back/prisma/migrations/20230920151856_/migration.sql
Normal 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");
|
||||
2
back/prisma/migrations/20230921103156_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;
|
||||
@@ -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")
|
||||
@@ -12,14 +18,26 @@ datasource db {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
email String
|
||||
password String?
|
||||
email String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
partyPlayed Int @default(0)
|
||||
LessonHistory LessonHistory[]
|
||||
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 {
|
||||
@@ -59,6 +77,7 @@ model Song {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
difficulties Json
|
||||
SongHistory SongHistory[]
|
||||
likedByUsers LikedSongs[]
|
||||
}
|
||||
|
||||
model SongHistory {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -7,11 +7,20 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpCode,
|
||||
Put,
|
||||
InternalServerErrorException,
|
||||
Patch,
|
||||
NotFoundException,
|
||||
Req,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpStatus,
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Query,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
@@ -19,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';
|
||||
@@ -32,6 +47,10 @@ import { Profile } from './dto/profile.dto';
|
||||
import { Setting } from 'src/models/setting';
|
||||
import { UpdateSettingDto } from 'src/settings/dto/update-setting.dto';
|
||||
import { SettingsService } from 'src/settings/settings.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { writeFile } from 'fs';
|
||||
import { PasswordResetDto } from './dto/password_reset.dto ';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -42,38 +61,148 @@ export class AuthController {
|
||||
private settingsService: SettingsService,
|
||||
) {}
|
||||
|
||||
@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 });
|
||||
if (!user) {
|
||||
user = await this.usersService.createUser(req.user);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
}
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@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)
|
||||
const user = await this.usersService.createUser(registerDto);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
} catch(e) {
|
||||
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);
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@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')
|
||||
async getProfilePicture(@Request() req: any, @Response() res: any) {
|
||||
return await this.usersService.getProfilePicture(req.user.id, res);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'The user profile picture' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Post('me/picture')
|
||||
@ApiOperation({ description: 'Upload a new profile picture' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async postProfilePicture(
|
||||
@Request() req: any,
|
||||
@UploadedFile(
|
||||
new ParseFilePipeBuilder()
|
||||
.addFileTypeValidator({
|
||||
fileType: 'jpeg',
|
||||
})
|
||||
.build({
|
||||
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
const path = `/data/${req.user.id}.jpg`;
|
||||
writeFile(path, file.buffer, (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||
@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();
|
||||
@@ -85,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>,
|
||||
@@ -110,32 +240,79 @@ 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 });
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Patch('me/settings')
|
||||
@ApiOperation({ description: 'Edit the settings of connected user' })
|
||||
udpateSettings(
|
||||
@Request() req: any,
|
||||
@Body() settingUserDto: UpdateSettingDto): Promise<Setting> {
|
||||
@Body() settingUserDto: UpdateSettingDto,
|
||||
): Promise<Setting> {
|
||||
return this.settingsService.updateUserSettings({
|
||||
where: { userId: +req.user.id},
|
||||
where: { userId: +req.user.id },
|
||||
data: settingUserDto,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({description: 'Successfully edited settings', type: Setting})
|
||||
@ApiUnauthorizedResponse({description: 'Invalid token'})
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/settings')
|
||||
@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 });
|
||||
const result = await this.settingsService.getUserSetting({
|
||||
userId: +req.user.id,
|
||||
});
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { SettingsModule } from 'src/settings/settings.module';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,12 +21,12 @@ import { SettingsModule } from 'src/settings/settings.module';
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '1h' },
|
||||
signOptions: { expiresIn: '365d' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy, GoogleStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import PayloadInterface from './interface/payload.interface';
|
||||
import { User } from 'src/models/user';
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private emailService: MailerService,
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
@@ -15,7 +18,7 @@ export class AuthService {
|
||||
password: string,
|
||||
): Promise<PayloadInterface | null> {
|
||||
const user = await this.userService.user({ username });
|
||||
if (user && bcrypt.compareSync(password, user.password)) {
|
||||
if (user && user.password && bcrypt.compareSync(password, user.password)) {
|
||||
return {
|
||||
username: user.username,
|
||||
id: user.id,
|
||||
@@ -31,4 +34,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;
|
||||
}
|
||||
}
|
||||
|
||||
8
back/src/auth/dto/password_reset.dto .ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PasswordResetDto {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
35
back/src/auth/google.strategy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
): Promise<any> {
|
||||
const user = {
|
||||
email: profile.emails[0].value,
|
||||
username: profile.displayName,
|
||||
password: null,
|
||||
googleID: profile.id,
|
||||
// firstName: name.givenName,
|
||||
// lastName: name.familyName,
|
||||
// picture: photos[0].value,
|
||||
};
|
||||
done(null, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SearchHistoryDto {
|
||||
@ApiProperty()
|
||||
query: string;
|
||||
|
||||
@ApiProperty()
|
||||
type: "song" | "artist" | "album" | "genre";
|
||||
type: 'song' | 'artist' | 'album' | 'genre';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNumber } from "class-validator";
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class SongHistoryDto {
|
||||
@ApiProperty()
|
||||
@@ -15,8 +15,8 @@ export class SongHistoryDto {
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
difficulties: Record<string, number>
|
||||
difficulties: Record<string, number>;
|
||||
|
||||
@ApiProperty()
|
||||
info: Record<string, number>
|
||||
info: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -10,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
describe('HistoryService', () => {
|
||||
let service: HistoryService;
|
||||
let service: HistoryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HistoryService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SongHistoryDto } from './dto/SongHistoryDto';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
constructor(private prisma: PrismaService) { }
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createSongHistoryRecord({
|
||||
songID,
|
||||
@@ -74,7 +74,7 @@ export class HistoryService {
|
||||
|
||||
async createSearchHistoryRecord(
|
||||
userID: number,
|
||||
{ query, type }: SearchHistoryDto
|
||||
{ query, type }: SearchHistoryDto,
|
||||
): Promise<SearchHistory> {
|
||||
return this.prisma.searchHistory.create({
|
||||
data: {
|
||||
|
||||
@@ -13,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)
|
||||
|
||||
@@ -1,24 +1,71 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const prismaService = app.get(PrismaService);
|
||||
await prismaService.enableShutdownHooks(app);
|
||||
app.use(
|
||||
RequestLogger.buildExpressRequestLogger({
|
||||
doNotLogPaths: ['/health'],
|
||||
} as RequestLoggerOptions),
|
||||
);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Chromacase')
|
||||
.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();
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -6,7 +6,7 @@ export class User {
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
email: string | null;
|
||||
@ApiProperty()
|
||||
isGuest: boolean;
|
||||
@ApiProperty()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
@@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
this.$on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SettingsService } from './settings.service';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
imports: [PrismaModule],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
|
||||
@@ -20,10 +20,10 @@ export class SettingsService {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserSettings(params: {
|
||||
@@ -37,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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, Param, NotFoundException } 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')
|
||||
@@ -20,4 +20,10 @@ export class UsersController {
|
||||
if (!ret) throw new NotFoundException();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@Get(':id/picture')
|
||||
@ApiOkResponse({description: 'Return the profile picture of the requested user'})
|
||||
async getPicture(@Response() res: any, @Param('id') id: number) {
|
||||
return await this.usersService.getProfilePicture(+id, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
|
||||
@@ -34,7 +42,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||
data.password = await bcrypt.hash(data.password, 8);
|
||||
if (data.password) data.password = await bcrypt.hash(data.password, 8);
|
||||
return this.prisma.user.create({
|
||||
data,
|
||||
});
|
||||
@@ -46,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: '',
|
||||
},
|
||||
});
|
||||
@@ -72,4 +80,56 @@ export class UsersService {
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfilePicture(userId: number, res: any) {
|
||||
const path = `/data/${userId}.jpg`;
|
||||
if (existsSync(path)) {
|
||||
const file = createReadStream(path);
|
||||
return file.pipe(res);
|
||||
}
|
||||
// We could not find a profile icon locally, using gravatar instead.
|
||||
const user = await this.user({ id: userId });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
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);
|
||||
resp.body!.pipe(res);
|
||||
}
|
||||
|
||||
async addLikedSong(
|
||||
userId: number,
|
||||
songId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.create(
|
||||
{
|
||||
data: { songId: songId, userId: userId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async getLikedSongs(
|
||||
userId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.findMany(
|
||||
{
|
||||
where: { userId: userId },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async removeLikedSong(
|
||||
userId: number,
|
||||
songId: number,
|
||||
) {
|
||||
return this.prisma.likedSongs.deleteMany(
|
||||
{
|
||||
where: { userId: userId, songId: songId },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ Register Duplicates
|
||||
# We can't use the `Register` keyword because it assert for success
|
||||
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
|
||||
Output
|
||||
Integer response status 400
|
||||
Integer response status 409
|
||||
Login user-duplicate
|
||||
[Teardown] DELETE /auth/me
|
||||
|
||||
|
||||
37
config/logs_nginx.conf
Normal 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
@@ -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
|
||||
22
config/promtail-local-config.yaml
Normal 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'
|
||||
|
||||
29
crawler/package-lock.json
generated
@@ -11,10 +11,12 @@
|
||||
"dependencies": {
|
||||
"crawlee": "^3.0.0",
|
||||
"fs": "^0.0.1-security",
|
||||
"playwright": "^1.28.0"
|
||||
"playwright": "^1.28.0",
|
||||
"slug": "^8.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apify/tsconfig": "^0.1.0",
|
||||
"@types/slug": "^5.0.5",
|
||||
"ts-node": "^10.8.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
@@ -778,6 +780,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/slug": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/slug/-/slug-5.0.5.tgz",
|
||||
"integrity": "sha512-vcHM79Xu5ALOC90kf5S1B4XGbRl8VW6f1+6jpBmK/FLHi4AyWKAVENgMOyHFyjHV5vDbNRPtjsNJuPRqrLBOxw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
|
||||
@@ -2760,6 +2768,14 @@
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
},
|
||||
"node_modules/slug": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/slug/-/slug-8.2.3.tgz",
|
||||
"integrity": "sha512-fXjhAZszNecz855GUNIwW0+sFPi9WV4bMiEKDOCA4wcq1ts1UnUVNy/F78B0Aat7/W3rA+se//33ILKNMrbeYQ==",
|
||||
"bin": {
|
||||
"slug": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -3848,6 +3864,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/slug": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/slug/-/slug-5.0.5.tgz",
|
||||
"integrity": "sha512-vcHM79Xu5ALOC90kf5S1B4XGbRl8VW6f1+6jpBmK/FLHi4AyWKAVENgMOyHFyjHV5vDbNRPtjsNJuPRqrLBOxw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/tough-cookie": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
|
||||
@@ -5233,6 +5255,11 @@
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
|
||||
},
|
||||
"slug": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/slug/-/slug-8.2.3.tgz",
|
||||
"integrity": "sha512-fXjhAZszNecz855GUNIwW0+sFPi9WV4bMiEKDOCA4wcq1ts1UnUVNy/F78B0Aat7/W3rA+se//33ILKNMrbeYQ=="
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"dependencies": {
|
||||
"crawlee": "^3.0.0",
|
||||
"fs": "^0.0.1-security",
|
||||
"playwright": "^1.28.0"
|
||||
"playwright": "^1.28.0",
|
||||
"slug": "^8.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apify/tsconfig": "^0.1.0",
|
||||
"@types/slug": "^5.0.5",
|
||||
"ts-node": "^10.8.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
|
||||
@@ -17,5 +17,5 @@ const crawler = new PlaywrightCrawler({
|
||||
|
||||
// Add first URL to the queue and start the crawl.
|
||||
await crawler.run([
|
||||
"https://musescore.com/sheetmusic?complexity=1&instrument=2&license=to_modify_commercially%2Cto_use_commercially&recording_type=public-domain",
|
||||
"https://musescore.com/sheetmusic?complexity=1&instrument=2&instrumentation=114&license=to_modify_commercially%2Cto_use_commercially&recording_type=public-domain&sort=rating",
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dataset, createPlaywrightRouter } from "crawlee";
|
||||
import * as fs from "fs";
|
||||
import { sleep } from "crawlee";
|
||||
export const router = createPlaywrightRouter();
|
||||
import slug from "slug";
|
||||
|
||||
router.addDefaultHandler(async ({ enqueueLinks }) => {
|
||||
const songs = await enqueueLinks({
|
||||
@@ -18,13 +19,17 @@ router.addDefaultHandler(async ({ enqueueLinks }) => {
|
||||
router.addHandler("SONG", async ({ request, page }) => {
|
||||
await Dataset.pushData({ url: request.loadedUrl });
|
||||
await page.waitForSelector('aside div div section button[name="download"]');
|
||||
const title = await page.locator("h1").textContent();
|
||||
const artist = await page
|
||||
let og_title = await page.locator("h1").textContent();
|
||||
if (og_title == null) return
|
||||
let title = slug(og_title);
|
||||
let artist = await page
|
||||
.locator(
|
||||
"body > div.js-page.react-container > div > section > aside > div:nth-child(5) > div > section > h3:nth-child(2) > a"
|
||||
)
|
||||
.first()
|
||||
.textContent();
|
||||
if (artist == null) return
|
||||
artist = slug(artist);
|
||||
const genres = await page
|
||||
.locator(
|
||||
"body > div.js-page.react-container > div > section > aside > div:nth-child(6) > div > table > tbody > tr:nth-child(5) > td > div > a"
|
||||
@@ -66,7 +71,7 @@ router.addHandler("SONG", async ({ request, page }) => {
|
||||
`../musics/a/${title}/${title}.ini`,
|
||||
`
|
||||
[Metadata]
|
||||
Name=${title}
|
||||
Name=${og_title}
|
||||
Artist=${artist}
|
||||
Genre=${genres}
|
||||
Album=
|
||||
|
||||
1
data/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -1,3 +1,10 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
volumes:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build:
|
||||
@@ -9,6 +16,7 @@ services:
|
||||
volumes:
|
||||
- ./back:/app
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -24,6 +32,9 @@ services:
|
||||
volumes:
|
||||
- ./scorometer:/app
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
networks:
|
||||
- loki
|
||||
|
||||
db:
|
||||
container_name: db
|
||||
@@ -39,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:
|
||||
@@ -54,3 +66,19 @@ services:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
environment:
|
||||
- API_URL=http://back:3000
|
||||
- SCOROMETER_URL=http://scorometer:6543
|
||||
- FRONT_URL=http://front:19006
|
||||
- PORT=4567
|
||||
depends_on:
|
||||
- back
|
||||
- front
|
||||
volumes:
|
||||
- "./front/assets:/assets:ro"
|
||||
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
|
||||
ports:
|
||||
- "4567:4567"
|
||||
|
||||
185
docker-compose.log.yml
Normal 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
|
||||
@@ -1,3 +1,9 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
volumes:
|
||||
scoro_logs:
|
||||
|
||||
services:
|
||||
back:
|
||||
image: ghcr.io/chroma-case/back:main
|
||||
@@ -10,18 +16,20 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
scorometer:
|
||||
image: ghcr.io/chroma-case/scorometer:main
|
||||
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"
|
||||
@@ -42,4 +50,4 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
- .env
|
||||
@@ -1,3 +1,12 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
|
||||
volumes:
|
||||
db:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build: ./back
|
||||
@@ -10,12 +19,14 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- ./data:/data
|
||||
scorometer:
|
||||
build: ./scorometer
|
||||
ports:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
@@ -34,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/
|
||||
@@ -48,7 +55,4 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
db:
|
||||
- .env
|
||||
43
flake.lock
generated
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1665573177,
|
||||
"narHash": "sha256-Arkrf3zmi3lXYpbSe9H+HQxswQ6jxsAmeQVq5Sr/OZc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d2afb051ffd904af5a825f58abee3c63b148c5f2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
31
flake.nix
@@ -1,31 +0,0 @@
|
||||
{
|
||||
description = "A prisma test project";
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/master";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
nativeBuildInputs = [ pkgs.bashInteractive ];
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prisma
|
||||
nodePackages."@nestjs/cli"
|
||||
nodePackages.npm
|
||||
nodejs-slim
|
||||
yarn
|
||||
python3
|
||||
pkg-config
|
||||
];
|
||||
shellHook = with pkgs; ''
|
||||
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
|
||||
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
|
||||
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
|
||||
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
|
||||
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
|
||||
export DATABASE_URL=postgresql://user:eip@localhost:5432/chromacase
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
|
||||
2
front/.gitignore
vendored
@@ -14,5 +14,7 @@ yarn.error*
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
yarn-error.log
|
||||
|
||||
.idea/
|
||||
.expo
|
||||
124
front/API.ts
@@ -4,10 +4,10 @@ import Chapter from './models/Chapter';
|
||||
import Lesson from './models/Lesson';
|
||||
import Genre, { GenreHandler } from './models/Genre';
|
||||
import LessonHistory from './models/LessonHistory';
|
||||
import likedSong, { LikedSongHandler } from './models/LikedSong';
|
||||
import Song, { SongHandler } from './models/Song';
|
||||
import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory';
|
||||
import User, { UserHandler } from './models/User';
|
||||
import Constants from 'expo-constants';
|
||||
import store from './state/Store';
|
||||
import { Platform } from 'react-native';
|
||||
import { en } from './i18n/Translations';
|
||||
@@ -21,6 +21,8 @@ import { PlageHandler } from './models/Plage';
|
||||
import { ListHandler } from './models/List';
|
||||
import { AccessTokenResponseHandler } from './models/AccessTokenResponse';
|
||||
import * as yup from 'yup';
|
||||
import { base64ToBlob } from './utils/base64ToBlob';
|
||||
import { ImagePickerAsset } from 'expo-image-picker';
|
||||
|
||||
type AuthenticationInput = { username: string; password: string };
|
||||
type RegistrationInput = AuthenticationInput & { email: string };
|
||||
@@ -30,6 +32,7 @@ export type AccessToken = string;
|
||||
type FetchParams = {
|
||||
route: string;
|
||||
body?: object;
|
||||
formData?: FormData;
|
||||
method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT';
|
||||
};
|
||||
|
||||
@@ -65,7 +68,7 @@ export default class API {
|
||||
public static readonly baseUrl =
|
||||
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
|
||||
? '/api'
|
||||
: Constants.manifest?.extra?.apiUrl;
|
||||
: 'https://nightly.chroma.octohub.app/api';
|
||||
public static async fetch(
|
||||
params: FetchParams,
|
||||
handle: Pick<Required<HandleParams>, 'raw'>
|
||||
@@ -81,17 +84,30 @@ export default class API {
|
||||
public static async fetch(params: FetchParams): Promise<void>;
|
||||
public static async fetch(params: FetchParams, handle?: HandleParams) {
|
||||
const jwtToken = store.getState().user.accessToken;
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
const headers = {
|
||||
...(params.formData == undefined && { 'Content-Type': 'application/json' }),
|
||||
...(jwtToken && { Authorization: `Bearer ${jwtToken}` }),
|
||||
};
|
||||
const response = await fetch(`${API.baseUrl}${params.route}`, {
|
||||
headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header,
|
||||
body: JSON.stringify(params.body),
|
||||
headers: headers,
|
||||
body: params.formData ?? JSON.stringify(params.body),
|
||||
method: params.method ?? 'GET',
|
||||
}).catch(() => {
|
||||
throw new Error('Error while fetching API: ' + API.baseUrl);
|
||||
});
|
||||
if (!handle || handle.emptyResponse) {
|
||||
if (!response.ok) {
|
||||
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;
|
||||
}
|
||||
if (handle.raw) {
|
||||
@@ -102,7 +118,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) {
|
||||
@@ -164,6 +180,7 @@ export default class API {
|
||||
{
|
||||
route: '/auth/guest',
|
||||
method: 'POST',
|
||||
body: undefined,
|
||||
},
|
||||
{ handler: AccessTokenResponseHandler }
|
||||
)
|
||||
@@ -278,6 +295,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
|
||||
@@ -313,6 +367,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
|
||||
@@ -587,4 +658,43 @@ export default class API {
|
||||
{ handler: UserHandler }
|
||||
);
|
||||
}
|
||||
|
||||
public static async updateProfileAvatar(image: ImagePickerAsset): Promise<void> {
|
||||
const data = await base64ToBlob(image.uri);
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('file', data);
|
||||
return API.fetch({
|
||||
route: '/auth/me/picture',
|
||||
method: 'POST',
|
||||
formData,
|
||||
});
|
||||
}
|
||||
|
||||
public static async addLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
public static async removeLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
public static getLikedSongs(): Query<likedSong[]> {
|
||||
return {
|
||||
key: ['liked songs'],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: '/auth/me/likes',
|
||||
},
|
||||
{ handler: ListHandler(LikedSongHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React 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}>
|
||||
|
||||
@@ -22,6 +22,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
|
||||
|
||||
@@ -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,6 +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 = () => '';
|
||||
@@ -39,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,
|
||||
@@ -58,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 },
|
||||
@@ -74,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 = () =>
|
||||
@@ -84,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: {
|
||||
@@ -100,6 +120,21 @@ const publicRoutes = () =>
|
||||
options: { title: 'Oops', headerShown: false },
|
||||
link: undefined,
|
||||
},
|
||||
Google: {
|
||||
component: GoogleView,
|
||||
options: { title: 'Google signin', headerShown: false },
|
||||
link: '/logged/google',
|
||||
},
|
||||
PasswordReset: {
|
||||
component: PasswordResetView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/password_reset',
|
||||
},
|
||||
ForgotPassword: {
|
||||
component: ForgotPasswordView,
|
||||
options: { title: 'Password reset form', headerShown: false },
|
||||
link: '/forgot_password',
|
||||
},
|
||||
} as const);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
143
front/Theme.tsx
@@ -12,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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": {
|
||||
@@ -32,6 +28,14 @@
|
||||
"eas": {
|
||||
"projectId": "dade8e5e-3e2c-49f7-98c5-cf8834c7ebb2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "The app accesses your photos to let you set your personal avatar."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
BIN
front/assets/auth/guest_banner.png
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
front/assets/auth/login_banner.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
front/assets/auth/register_banner.png
Normal file
|
After Width: | Height: | Size: 609 KiB |
BIN
front/assets/banner.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 18 KiB |
BIN
front/assets/fonts/Lexend-VariableFont_wght.ttf
Normal file
BIN
front/assets/fonts/lexend.ttf
Normal file
BIN
front/assets/full_dark.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
front/assets/full_light.png
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
front/assets/icon.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 65 KiB |
BIN
front/assets/icon_dark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
front/assets/icon_light.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
front/assets/metronome.mp3
Normal file
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 32 KiB |
BIN
front/assets/title_dark.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
front/assets/title_light.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
@@ -16,7 +16,7 @@ import useColorScheme from '../hooks/colorScheme';
|
||||
type BigActionButtonProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
image?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
iconName?: string;
|
||||
// It is not possible to recover the type, the `Icon` parameter is `any` as well.
|
||||
|
||||
85
front/components/FavSongRow.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
37
front/components/Metronome.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
front/components/PartitionCoord.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import PartitionView from './PartitionView';
|
||||
import PhaserCanvas from './PartitionVisualizer/PhaserCanvas';
|
||||
import { PianoCursorPosition } from '../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;
|
||||
};
|
||||
|
||||
const PartitionCoord = ({
|
||||
file,
|
||||
onPartitionReady,
|
||||
onEndReached,
|
||||
onPause,
|
||||
onResume,
|
||||
bpmRef,
|
||||
}: PartitionCoordProps) => {
|
||||
const [partitionData, setPartitionData] = React.useState<
|
||||
[[number, number], string, PianoCursorPosition[]] | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!partitionData && (
|
||||
<PartitionView
|
||||
file={file}
|
||||
bpmRef={bpmRef}
|
||||
onPartitionReady={(dims, base64data, a) => {
|
||||
setPartitionData([dims, base64data, a]);
|
||||
onPartitionReady();
|
||||
}}
|
||||
onEndReached={() => {
|
||||
console.log('osmd end reached');
|
||||
}}
|
||||
timestamp={0}
|
||||
/>
|
||||
)}
|
||||
{partitionData && (
|
||||
<PhaserCanvas
|
||||
partitionDims={partitionData?.[0]}
|
||||
partitionB64={partitionData?.[1]}
|
||||
cursorPositions={partitionData?.[2]}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onEndReached={() => {
|
||||
onEndReached();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartitionCoord;
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-mixed-spaces-and-tabs */
|
||||
// Inspired from OSMD example project
|
||||
// https://github.com/opensheetmusicdisplay/react-opensheetmusicdisplay/blob/master/src/lib/OpenSheetMusicDisplay.jsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { MutableRefObject, useEffect } from 'react';
|
||||
import {
|
||||
CursorType,
|
||||
Fraction,
|
||||
@@ -10,29 +10,27 @@ import {
|
||||
Note,
|
||||
} from 'opensheetmusicdisplay';
|
||||
import useColorScheme from '../hooks/colorScheme';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import SoundFont from 'soundfont-player';
|
||||
import * as SAC from 'standardized-audio-context';
|
||||
|
||||
import { PianoCursorPosition } from '../models/PianoGame';
|
||||
type PartitionViewProps = {
|
||||
// The Buffer of the MusicXML file retreived from the API
|
||||
file: string;
|
||||
onPartitionReady: () => void;
|
||||
onPartitionReady: (
|
||||
dims: [number, number],
|
||||
base64data: string,
|
||||
cursorInfos: PianoCursorPosition[]
|
||||
) => void;
|
||||
bpmRef: MutableRefObject<number>;
|
||||
onEndReached: () => void;
|
||||
// Timestamp of the play session, in milisecond
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PartitionView = (props: PartitionViewProps) => {
|
||||
const [osmd, setOsmd] = useState<OSMD>();
|
||||
const [soundPlayer, setSoundPlayer] = useState<SoundFont.Player>();
|
||||
const audioContext = new SAC.AudioContext();
|
||||
const [wholeNoteLength, setWholeNoteLength] = useState(0); // Length of Whole note, in ms (?)
|
||||
const colorScheme = useColorScheme();
|
||||
const dimensions = useWindowDimensions();
|
||||
const OSMD_DIV_ID = 'osmd-div';
|
||||
const options: IOSMDOptions = {
|
||||
darkMode: colorScheme == 'dark',
|
||||
backend: 'canvas',
|
||||
drawComposer: false,
|
||||
drawCredits: false,
|
||||
drawLyrics: false,
|
||||
@@ -43,15 +41,15 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
autoResize: false,
|
||||
};
|
||||
// Turns note.Length or timestamp in ms
|
||||
const timestampToMs = (timestamp: Fraction) => {
|
||||
const timestampToMs = (timestamp: Fraction, wholeNoteLength: number) => {
|
||||
return timestamp.RealValue * wholeNoteLength;
|
||||
};
|
||||
const getActualNoteLength = (note: Note) => {
|
||||
let duration = timestampToMs(note.Length);
|
||||
const getActualNoteLength = (note: Note, wholeNoteLength: number) => {
|
||||
let duration = timestampToMs(note.Length, wholeNoteLength);
|
||||
if (note.NoteTie) {
|
||||
const firstNote = note.NoteTie.Notes.at(1);
|
||||
if (Object.is(note.NoteTie.StartNote, note) && firstNote) {
|
||||
duration += timestampToMs(firstNote.Length);
|
||||
duration += timestampToMs(firstNote.Length, wholeNoteLength);
|
||||
} else {
|
||||
duration = 0;
|
||||
}
|
||||
@@ -59,99 +57,85 @@ const PartitionView = (props: PartitionViewProps) => {
|
||||
return duration;
|
||||
};
|
||||
|
||||
const playNotesUnderCursor = () => {
|
||||
osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.filter((note) => note.isRest() == false)
|
||||
.filter((note) => note.Pitch) // Pitch Can be null, avoiding them
|
||||
.forEach((note) => {
|
||||
// Put your hands together for https://github.com/jimutt/osmd-audio-player/blob/master/src/internals/noteHelpers.ts
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
// console.log('Expecting midi ' + midiNumber);
|
||||
const duration = getActualNoteLength(note);
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
soundPlayer!.play(midiNumber.toString(), audioContext.currentTime, {
|
||||
duration,
|
||||
gain,
|
||||
});
|
||||
});
|
||||
};
|
||||
const getShortedNoteUnderCursor = () => {
|
||||
return osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
|
||||
.at(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const _osmd = new OSMD(OSMD_DIV_ID, options);
|
||||
Promise.all([
|
||||
SoundFont.instrument(audioContext as unknown as AudioContext, 'electric_piano_1'),
|
||||
_osmd.load(props.file),
|
||||
]).then(([player]) => {
|
||||
setSoundPlayer(player);
|
||||
Promise.all([_osmd.load(props.file)]).then(() => {
|
||||
_osmd.render();
|
||||
_osmd.cursor.hide();
|
||||
// Ty https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL77C12-L77C63
|
||||
_osmd.cursor.show();
|
||||
const bpm = _osmd.Sheet.HasBPMInfo ? _osmd.Sheet.getExpressionsStartTempoInBPM() : 60;
|
||||
setWholeNoteLength(Math.round((60 / bpm) * 4000));
|
||||
props.onPartitionReady();
|
||||
props.bpmRef.current = bpm;
|
||||
const wholeNoteLength = Math.round((60 / bpm) * 4000);
|
||||
const curPos = [];
|
||||
while (!_osmd.cursor.iterator.EndReached) {
|
||||
const notesToPlay = _osmd.cursor
|
||||
.NotesUnderCursor()
|
||||
.filter((note) => {
|
||||
return note.isRest() == false && note.Pitch;
|
||||
})
|
||||
.map((note) => {
|
||||
return {
|
||||
note: note,
|
||||
duration: getActualNoteLength(note, wholeNoteLength),
|
||||
};
|
||||
});
|
||||
const shortestNotes = _osmd!.cursor
|
||||
.NotesUnderCursor()
|
||||
.sort((n1, n2) => n1.Length.CompareTo(n2.Length))
|
||||
.at(0);
|
||||
const ts = timestampToMs(
|
||||
shortestNotes?.getAbsoluteTimestamp() ?? new Fraction(-1),
|
||||
wholeNoteLength
|
||||
);
|
||||
const sNL = timestampToMs(
|
||||
shortestNotes?.Length ?? new Fraction(-1),
|
||||
wholeNoteLength
|
||||
);
|
||||
curPos.push({
|
||||
offset: _osmd.cursor.cursorElement.offsetLeft,
|
||||
notes: notesToPlay,
|
||||
shortedNotes: shortestNotes,
|
||||
sNinfos: {
|
||||
ts,
|
||||
sNL,
|
||||
isRest: shortestNotes?.isRest(),
|
||||
},
|
||||
});
|
||||
_osmd.cursor.next();
|
||||
}
|
||||
_osmd.cursor.reset();
|
||||
_osmd.cursor.hide();
|
||||
// console.log('timestamp cursor', _osmd.cursor.iterator.CurrentSourceTimestamp);
|
||||
// console.log('timestamp cursor', _osmd.cursor.iterator.CurrentVoiceEntries);
|
||||
// console.log('current measure index', _osmd.cursor.iterator.CurrentMeasureIndex);
|
||||
const osmdCanvas = document.querySelector<HTMLCanvasElement>(
|
||||
'#' + OSMD_DIV_ID + ' canvas'
|
||||
);
|
||||
// this should never happen this is done to silent ts linter about maybe null
|
||||
if (!osmdCanvas) {
|
||||
throw new Error('No canvas found');
|
||||
}
|
||||
let scale = osmdCanvas.width / parseFloat(osmdCanvas.style.width);
|
||||
if (Number.isNaN(scale)) {
|
||||
console.error('Scale is NaN setting it to 1');
|
||||
scale = 1;
|
||||
}
|
||||
// Ty https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL77C12-L77C63
|
||||
props.onPartitionReady(
|
||||
[osmdCanvas.width, osmdCanvas.height],
|
||||
osmdCanvas.toDataURL(),
|
||||
curPos.map((pos) => {
|
||||
return {
|
||||
x: pos.offset * scale,
|
||||
timing: pos.sNinfos.sNL,
|
||||
timestamp: pos.sNinfos.ts,
|
||||
notes: pos.notes,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Do not show cursor before actuall start
|
||||
});
|
||||
setOsmd(_osmd);
|
||||
}, []);
|
||||
|
||||
// Re-render manually (otherwise done by 'autoResize' option), to fix disappearing cursor
|
||||
useEffect(() => {
|
||||
if (osmd && osmd.IsReadyToRender()) {
|
||||
osmd.render();
|
||||
if (!osmd.cursor.hidden) {
|
||||
osmd.cursor.show();
|
||||
}
|
||||
}
|
||||
}, [dimensions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!osmd || !soundPlayer) {
|
||||
return;
|
||||
}
|
||||
if (props.timestamp > 0 && osmd.cursor.hidden && !osmd.cursor.iterator.EndReached) {
|
||||
osmd.cursor.show();
|
||||
playNotesUnderCursor();
|
||||
return;
|
||||
}
|
||||
let previousCursorPosition = -1;
|
||||
let currentCursorPosition = osmd.cursor.cursorElement.offsetLeft;
|
||||
let shortestNote = getShortedNoteUnderCursor();
|
||||
while (
|
||||
!osmd.cursor.iterator.EndReached &&
|
||||
(shortestNote?.isRest
|
||||
? timestampToMs(shortestNote?.getAbsoluteTimestamp() ?? new Fraction(-1)) +
|
||||
timestampToMs(shortestNote?.Length ?? new Fraction(-1)) <
|
||||
props.timestamp
|
||||
: timestampToMs(shortestNote?.getAbsoluteTimestamp() ?? new Fraction(-1)) <
|
||||
props.timestamp)
|
||||
) {
|
||||
previousCursorPosition = currentCursorPosition;
|
||||
osmd.cursor.next();
|
||||
if (osmd.cursor.iterator.EndReached) {
|
||||
osmd.cursor.hide(); // Lousy fix for https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/issues/1338
|
||||
soundPlayer.stop();
|
||||
props.onEndReached();
|
||||
} else {
|
||||
// Shamelessly stolen from https://github.com/jimutt/osmd-audio-player/blob/ec205a6e46ee50002c1fa8f5999389447bba7bbf/src/PlaybackEngine.ts#LL223C7-L224C1
|
||||
playNotesUnderCursor();
|
||||
currentCursorPosition = osmd.cursor.cursorElement.offsetLeft;
|
||||
document
|
||||
.getElementById(OSMD_DIV_ID)
|
||||
?.scrollBy(currentCursorPosition - previousCursorPosition, 0);
|
||||
shortestNote = getShortedNoteUnderCursor();
|
||||
}
|
||||
}
|
||||
}, [props.timestamp]);
|
||||
|
||||
return <div id={OSMD_DIV_ID} style={{ width: '100%', overflow: 'hidden' }} />;
|
||||
};
|
||||
|
||||
|
||||
249
front/components/PartitionVisualizer/PhaserCanvas.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
// create a simple phaser effect with a canvas that can be easily imported as a react component
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useContext } from 'react';
|
||||
import { Dimensions } from 'react-native';
|
||||
import Phaser from 'phaser';
|
||||
import useColorScheme from '../../hooks/colorScheme';
|
||||
import { RootState, useSelector } from '../../state/Store';
|
||||
import { setSoundPlayer as setSPStore } from '../../state/SoundPlayerSlice';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { SplendidGrandPiano, CacheStorage } from 'smplr';
|
||||
import { handlePianoGameMsg } from './PianoGameUpdateFunctions';
|
||||
import { PianoCC } from '../../views/PlayView';
|
||||
import { PianoCanvasMsg, PianoCursorNote, PianoCursorPosition } from '../../models/PianoGame';
|
||||
|
||||
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)) {
|
||||
return a[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const playNotes = (notes: PianoCursorNote[], soundPlayer: SplendidGrandPiano) => {
|
||||
notes.forEach(({ note, duration }) => {
|
||||
const fixedKey =
|
||||
note.ParentVoiceEntry.ParentVoice.Parent.SubInstruments.at(0)?.fixedKey ?? 0;
|
||||
const midiNumber = note.halfTone - fixedKey * 12;
|
||||
const gain = note.ParentVoiceEntry.ParentVoice.Volume;
|
||||
soundPlayer.start({ note: midiNumber, duration, velocity: gain * 127 });
|
||||
});
|
||||
};
|
||||
|
||||
const getPianoScene = (
|
||||
partitionB64: string,
|
||||
cursorPositions: PianoCursorPosition[],
|
||||
onEndReached: () => void,
|
||||
soundPlayer: SplendidGrandPiano,
|
||||
colorScheme: 'light' | 'dark'
|
||||
) => {
|
||||
class PianoScene extends Phaser.Scene {
|
||||
async preload() {}
|
||||
private cursorPositionsIdx = -1;
|
||||
private partition!: Phaser.GameObjects.Image;
|
||||
private cursor!: Phaser.GameObjects.Rectangle;
|
||||
private emitter!: Phaser.GameObjects.Particles.ParticleEmitter;
|
||||
private nbTextureToload!: number;
|
||||
create() {
|
||||
this.textures.addBase64(
|
||||
'star',
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEQAAAApCAYAAACMeY82AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDE0IDc5LjE1Njc5NywgMjAxNC8wOC8yMC0wOTo1MzowMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTQgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjYyNzNEOEU4NzUxMzExRTRCN0ZCODQ1QUJCREFFQzA4IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjYyNzNEOEU5NzUxMzExRTRCN0ZCODQ1QUJCREFFQzA4Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NjI3M0Q4RTY3NTEzMTFFNEI3RkI4NDVBQkJEQUVDMDgiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NjI3M0Q4RTc3NTEzMTFFNEI3RkI4NDVBQkJEQUVDMDgiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4vDiTDAAAB4UlEQVR42uxagY2DMAw0VRdghayQFVghs3SF/1XaEdoVGIGswAj5OK0rkwfalx5wiY2smAik+OrYFxcAAWLABFQJazmAykCOEhZRx0sBYdLHS0VFk6omVU2qOwRktS1jwYY5QKSAslqEtNBWM0lVt4xUQERUmdrWSV+VZi27xVYZU1OimYyOmJTRDB58VVyE5BUJQYhJGZYGYzMH87nuqwuoRSRVdLyB5hcoWIZxjs9P2buT3LkI0PP+BKcQriEp2jjnwO0XjDFwUDFRouNXF5HoQlK0iwKDVw10/GzPdzBIoo1zBApF1prb57iUw/zQxkdkpY1rwNhoOQMDkhpt62wqw+ZisMTiOwNQqLu2VMWpxhggOcBbe/zwlTvKqTfZxDyJYyAAfEyPTTF2/1AcWj8Ye39fU98+geHlebBuvv4xX8Zal5WoCEGnvn1y/na5JQdz55aOkM3knRyS5xwpbcZ/BSG//2uVWTrB6uFOAjHjoT9GzIojZzlYdJaRQNcPW0cMby3OtRl32w950PbU2yAAiGPk22qL0rp4hOSlEkFAfvGi6RwenCXsLkLGfuUcDGKf/J2tIkTGv/9t/xaQxQDCzyPFJdU1AYns5kO3jKAPZhQQPctohHweIJK+D/kRYAAaWClvtE6otAAAAABJRU5ErkJggg=='
|
||||
);
|
||||
this.textures.addBase64('partition', partitionB64);
|
||||
this.cursorPositionsIdx = -1;
|
||||
// this is to prevent multiple initialisation of the scene
|
||||
this.nbTextureToload = 2;
|
||||
|
||||
this.cameras.main.setBackgroundColor(colorScheme === 'light' ? '#FFFFFF' : '#000000');
|
||||
this.textures.on('onload', () => {
|
||||
this.nbTextureToload--;
|
||||
if (this.nbTextureToload > 0) return;
|
||||
this.partition = this.add.image(0, 0, 'partition').setOrigin(0, 0);
|
||||
this.cameras.main.setBounds(0, 0, this.partition.width, this.partition.height);
|
||||
|
||||
const dims = this.partition.getBounds();
|
||||
// base ref normal cursor is 276px by 30px
|
||||
this.cursor = this.add
|
||||
.rectangle(0, 0, (dims.height * 30) / 276, dims.height, 0x31ef8c, 0.5)
|
||||
.setOrigin(0, 0);
|
||||
this.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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override update() {
|
||||
const currentTimestamp = globalTimestamp;
|
||||
const status = globalStatus;
|
||||
|
||||
if (status === 'playing') {
|
||||
const transitionTime = 75;
|
||||
const cP = myFindLast(cursorPositions, (cP: { timestamp: number }, idx: number) => {
|
||||
if (
|
||||
cP.timestamp < currentTimestamp + transitionTime &&
|
||||
idx > this.cursorPositionsIdx
|
||||
) {
|
||||
this.cursorPositionsIdx = idx;
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
playNotes(cP.notes, soundPlayer);
|
||||
const tw = {
|
||||
targets: this!.cursor,
|
||||
x: cP!.x,
|
||||
duration: transitionTime,
|
||||
ease: 'Sine.easeInOut',
|
||||
onComplete: undefined as (() => void) | undefined,
|
||||
};
|
||||
if (this.cursorPositionsIdx === cursorPositions.length - 1) {
|
||||
tw.onComplete = () => {
|
||||
onEndReached();
|
||||
};
|
||||
}
|
||||
this.tweens.add(tw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PianoScene;
|
||||
};
|
||||
|
||||
export type PhaserCanvasProps = {
|
||||
partitionDims: [number, number];
|
||||
partitionB64: string;
|
||||
cursorPositions: PianoCursorPosition[];
|
||||
onEndReached: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
};
|
||||
|
||||
const PhaserCanvas = ({
|
||||
partitionDims,
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
}: PhaserCanvasProps) => {
|
||||
const colorScheme = useColorScheme();
|
||||
const dispatch = useDispatch();
|
||||
const pianoCC = useContext(PianoCC);
|
||||
const soundPlayer = useSelector((state: RootState) => state.soundPlayer.soundPlayer);
|
||||
const [game, setGame] = React.useState<Phaser.Game | null>(null);
|
||||
|
||||
globalTimestamp = pianoCC.timestamp;
|
||||
globalPressedKeys = pianoCC.pressedKeys;
|
||||
globalMessages = pianoCC.messages;
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidSoundPlayer(soundPlayer)) {
|
||||
return;
|
||||
}
|
||||
new SplendidGrandPiano(new AudioContext(), {
|
||||
storage: new CacheStorage(),
|
||||
})
|
||||
.loaded()
|
||||
.then((sp) => {
|
||||
dispatch(setSPStore(sp));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidSoundPlayer(soundPlayer) || !soundPlayer) return;
|
||||
const pianoScene = getPianoScene(
|
||||
partitionB64,
|
||||
cursorPositions,
|
||||
onEndReached,
|
||||
soundPlayer,
|
||||
colorScheme
|
||||
);
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
class UIScene extends Phaser.Scene {
|
||||
private statusTextValue: string;
|
||||
private statusText!: Phaser.GameObjects.Text;
|
||||
constructor() {
|
||||
super({ key: 'UIScene', active: true });
|
||||
|
||||
this.statusTextValue = 'Score: 0 Streak: 0';
|
||||
}
|
||||
|
||||
create() {
|
||||
this.statusText = this.add.text(
|
||||
this.cameras.main.width - 300,
|
||||
10,
|
||||
this.statusTextValue,
|
||||
{
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 25,
|
||||
color: '#3A784B',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
override update() {
|
||||
if (globalMessages.length > 0) {
|
||||
handlePianoGameMsg(globalMessages, undefined, this.statusText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
parent: 'phaser-canvas',
|
||||
width: max(width * 0.9, 850),
|
||||
height: min(max(height * 0.7, 400), partitionDims[1]),
|
||||
scene: [pianoScene, UIScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_HORIZONTALLY,
|
||||
},
|
||||
};
|
||||
|
||||
setGame(new Phaser.Game(config));
|
||||
return () => {
|
||||
if (game) {
|
||||
// currently the condition is always false
|
||||
game.destroy(true);
|
||||
}
|
||||
};
|
||||
}, [soundPlayer]);
|
||||
|
||||
return <div id="phaser-canvas"></div>;
|
||||
};
|
||||
|
||||
export default PhaserCanvas;
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { translate } from '../i18n/i18n';
|
||||
import { Box, Text, VStack, Progress, Stack, AspectRatio } from 'native-base';
|
||||
import { Box, Text, VStack, Progress, Stack } from 'native-base';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { Image } from 'native-base';
|
||||
import Card from '../components/Card';
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
const ProgressBar = ({ xp }: { xp: number }) => {
|
||||
const level = Math.floor(xp / 1000);
|
||||
@@ -15,18 +15,8 @@ const ProgressBar = ({ xp }: { xp: number }) => {
|
||||
|
||||
return (
|
||||
<Card w="100%" onPress={() => nav.navigate('User')}>
|
||||
<Stack padding={4} space={2} direction="row">
|
||||
<AspectRatio ratio={1}>
|
||||
<Image
|
||||
position="relative"
|
||||
borderRadius={100}
|
||||
source={{
|
||||
uri: 'https://wallpaperaccess.com/full/317501.jpg', // TODO : put the actual profile pic
|
||||
}}
|
||||
alt="Profile picture"
|
||||
zIndex={0}
|
||||
/>
|
||||
</AspectRatio>
|
||||
<Stack padding={4} space={2} direction="row" alignItems="center">
|
||||
<UserAvatar />
|
||||
<VStack alignItems={'center'} flexGrow={1} space={2}>
|
||||
<Text>{`${translate('level')} ${level}`}</Text>
|
||||
<Box w="100%">
|
||||
|
||||
34
front/components/RowCustom.tsx
Normal 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;
|
||||