Compare commits
3 Commits
main
...
guest-user
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec07b7e99 | ||
|
|
f93968c3eb | ||
|
|
f80253cea3 |
@@ -16,10 +16,9 @@ GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
|
||||
SMTP_TRANSPORT=smtps://toto:tata@relay
|
||||
MAIL_AUTHOR='"Chromacase" <chromacase@octohub.app>'
|
||||
IGNORE_MAILS=true
|
||||
API_KEYS=SCOROTEST,ROBOTO,SCORO,POPULATE
|
||||
API_KEYS=SCOROTEST,ROBOTO,SCORO
|
||||
API_KEY_SCORO_TEST=SCOROTEST
|
||||
API_KEY_ROBOT=ROBOTO
|
||||
API_KEY_SCORO=SCORO
|
||||
API_KEY_POPULATE=POPULATE
|
||||
MEILI_MASTER_KEY="ghvjkgisbgkbgskegblfqbgjkebbhgwkjfb"
|
||||
# vi: ft=sh
|
||||
|
||||
23
.github/workflows/back.yml
vendored
23
.github/workflows/back.yml
vendored
@@ -12,30 +12,27 @@ jobs:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
back: ${{ steps.filter.outputs.back }}
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
scorometer: ${{ steps.filter.outputs.scorometer }}
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
back:
|
||||
- 'back/**'
|
||||
- '.github/workflows/back.yml'
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
scorometer:
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
- '.github/workflows/scoro.yml'
|
||||
back_build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.back == 'true' }}
|
||||
if: ${{ needs.changes.outputs.backend == 'true' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./back
|
||||
@@ -50,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [ back_build ]
|
||||
if: ${{ needs.changes.outputs.back == 'true' }}
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
23
.github/workflows/front.yml
vendored
23
.github/workflows/front.yml
vendored
@@ -12,31 +12,28 @@ jobs:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
back: ${{ steps.filter.outputs.back }}
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
scorometer: ${{ steps.filter.outputs.scorometer }}
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
back:
|
||||
- 'back/**'
|
||||
- '.github/workflows/back.yml'
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
scorometer:
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
- '.github/workflows/scoro.yml'
|
||||
front_check:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' }}
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
@@ -57,7 +54,7 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
if: ${{ needs.changes.outputs.front == 'true' }}
|
||||
if: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
needs: [ front_check ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
21
.github/workflows/scoro.yml
vendored
21
.github/workflows/scoro.yml
vendored
@@ -11,28 +11,25 @@ jobs:
|
||||
pull-requests: read
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
back: ${{ steps.filter.outputs.back }}
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
scorometer: ${{ steps.filter.outputs.scorometer }}
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
scoro: ${{ steps.filter.outputs.scoro }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
back:
|
||||
- 'back/**'
|
||||
- '.github/workflows/back.yml'
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
scorometer:
|
||||
backend:
|
||||
- 'backend/**'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
scoro:
|
||||
- 'scorometer/**'
|
||||
- '.github/workflows/scoro.yml'
|
||||
scoro_test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scorometer == 'true' }}
|
||||
if: ${{ needs.changes.outputs.scoro == 'true' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Iterate through subfolders
|
||||
find . -type d | while read -r dir; do
|
||||
# Check if .midi file exists in the subfolder
|
||||
midi_file=$(find "$dir" -maxdepth 1 -type f -name '*.midi' | head -n 1)
|
||||
|
||||
if [ -n "$midi_file" ]; then
|
||||
# Create output file name (melody.mp3) in the same subfolder
|
||||
output_file="${dir}/melody.mp3"
|
||||
|
||||
# Run the given command
|
||||
#timidity "$midi_file" -Ow -o - | ffmpeg -i - -acodec libmp3lame -ab 64k "$output_file"
|
||||
fluidsynth -a alsa -T raw -F - "$midi_file" | ffmpeg -f s32le -i - "$output_file"
|
||||
|
||||
echo "Converted: $midi_file to $output_file"
|
||||
fi
|
||||
done
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
assets/musics/Short/Short.mid
Normal file
BIN
assets/musics/Short/Short.mid
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
FROM node:18.10.0
|
||||
FROM node:17
|
||||
WORKDIR /app
|
||||
CMD npm i ; npx prisma generate ; npx prisma migrate dev ; npm run start:dev
|
||||
|
||||
10761
back/package-lock.json
generated
10761
back/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,6 @@ import {
|
||||
Response,
|
||||
Query,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard";
|
||||
@@ -52,6 +51,7 @@ import { PasswordResetDto } from "./dto/password_reset.dto ";
|
||||
import { mapInclude } from "src/utils/include";
|
||||
import { SongController } from "src/song/song.controller";
|
||||
import { ChromaAuthGuard } from "./chroma-auth.guard";
|
||||
import { GuestDto } from "./dto/guest.dto";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@@ -163,8 +163,8 @@ export class AuthController {
|
||||
@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();
|
||||
async guest(@Body() guestdto: GuestDto): Promise<JwtToken> {
|
||||
const user = await this.usersService.createGuest(guestdto.username);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
return this.authService.login(user);
|
||||
}
|
||||
@@ -288,8 +288,8 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: "Successfully added liked song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Post("me/likes/:id")
|
||||
addLikedSong(@Request() req: any, @Param("id", ParseIntPipe) songId: number) {
|
||||
return this.usersService.addLikedSong(+req.user.id, songId);
|
||||
addLikedSong(@Request() req: any, @Param("id") songId: number) {
|
||||
return this.usersService.addLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -297,11 +297,8 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: "Successfully removed liked song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Delete("me/likes/:id")
|
||||
removeLikedSong(
|
||||
@Request() req: any,
|
||||
@Param("id", ParseIntPipe) songId: number,
|
||||
) {
|
||||
return this.usersService.removeLikedSong(+req.user.id, songId);
|
||||
removeLikedSong(@Request() req: any, @Param("id") songId: number) {
|
||||
return this.usersService.removeLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -321,7 +318,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: "Successfully added score" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@Patch("me/score/:score")
|
||||
addScore(@Request() req: any, @Param("score", ParseIntPipe) score: number) {
|
||||
addScore(@Request() req: any, @Param("id") score: number) {
|
||||
return this.usersService.addScore(+req.user.id, score);
|
||||
}
|
||||
}
|
||||
|
||||
8
back/src/auth/dto/guest.dto.ts
Normal file
8
back/src/auth/dto/guest.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class GuestDto {
|
||||
@ApiProperty()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Put } from "@nestjs/common";
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { ScoresService } from "./scores.service";
|
||||
import { User } from "@prisma/client";
|
||||
@@ -13,10 +13,4 @@ export class ScoresController {
|
||||
getTopTwenty(): Promise<User[]> {
|
||||
return this.scoresService.topTwenty();
|
||||
}
|
||||
|
||||
// @ApiOkResponse{{description: "Successfully updated the user's total score"}}
|
||||
// @Put("/add")
|
||||
// addScore(): Promise<void> {
|
||||
// return this.ScoresService.add()
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Request,
|
||||
@@ -13,13 +16,15 @@ import {
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { Artist, Song } from "@prisma/client";
|
||||
import { Artist, Genre, Song } from "@prisma/client";
|
||||
import { JwtAuthGuard } from "src/auth/jwt-auth.guard";
|
||||
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";
|
||||
import { mapInclude } from "src/utils/include";
|
||||
import { SongController } from "src/song/song.controller";
|
||||
import { GenreController } from "src/genre/genre.controller";
|
||||
import { ArtistController } from "src/artist/artist.controller";
|
||||
|
||||
@ApiTags("search")
|
||||
@@ -28,21 +33,21 @@ import { ArtistController } from "src/artist/artist.controller";
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get("songs")
|
||||
@Get("songs/:query")
|
||||
@ApiOkResponse({ type: _Song, isArray: true })
|
||||
@ApiOperation({ description: "Search a song" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
async searchSong(
|
||||
@Request() req: any,
|
||||
@Query("q") query: string | null,
|
||||
@Query("artistId", new ParseIntPipe({ optional: true })) artistId: number,
|
||||
@Query("genreId", new ParseIntPipe({ optional: true })) genreId: number,
|
||||
@Param("query") query: string,
|
||||
@Query("artistId") artistId: number,
|
||||
@Query("genreId") genreId: number,
|
||||
@Query("include") include: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Song[]> {
|
||||
): Promise<Song[] | null> {
|
||||
return await this.searchService.searchSong(
|
||||
query ?? "",
|
||||
query,
|
||||
artistId,
|
||||
genreId,
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
@@ -51,7 +56,7 @@ export class SearchController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("artists")
|
||||
@Get("artists/:query")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: _Artist, isArray: true })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid token" })
|
||||
@@ -59,12 +64,12 @@ export class SearchController {
|
||||
async searchArtists(
|
||||
@Request() req: any,
|
||||
@Query("include") include: string,
|
||||
@Query("q") query: string | null,
|
||||
@Param("query") query: string,
|
||||
@Query("skip", new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query("take", new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Artist[]> {
|
||||
): Promise<Artist[] | null> {
|
||||
return await this.searchService.searchArtists(
|
||||
query ?? "",
|
||||
query,
|
||||
mapInclude(include, req, ArtistController.includableFields),
|
||||
skip,
|
||||
take,
|
||||
|
||||
@@ -177,31 +177,6 @@ export class SongController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get(":id/assets/melody")
|
||||
@ApiOperation({
|
||||
description: "Streams the mp3 file of the requested song",
|
||||
})
|
||||
@ApiNotFoundResponse({ description: "Song not found" })
|
||||
@ApiOkResponse({ description: "Returns the mp3 file succesfully" })
|
||||
@Header("Cache-Control", "max-age=86400")
|
||||
@Header("Content-Type", "audio/mpeg")
|
||||
@Public()
|
||||
async getMelody(@Param("id", ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
if (!song) throw new NotFoundException("Song not found");
|
||||
|
||||
const path = song.musicXmlPath;
|
||||
// mp3 file is next to the musicXML file and called melody.mp3
|
||||
const pathWithoutFile = path.substring(0, path.lastIndexOf("/"));
|
||||
|
||||
try {
|
||||
const file = createReadStream(pathWithoutFile + "/melody.mp3");
|
||||
return new StreamableFile(file, { type: "audio/mpeg" });
|
||||
} catch {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
description:
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { User, Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, existsSync } from "fs";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
@@ -46,10 +46,10 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async createGuest(): Promise<User> {
|
||||
async createGuest(displayName: string): Promise<User> {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
username: `Guest ${randomUUID()}`,
|
||||
username: displayName,
|
||||
isGuest: true,
|
||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||
email: null,
|
||||
@@ -122,7 +122,7 @@ export class UsersService {
|
||||
return this.prisma.user.update({
|
||||
where: { id: where },
|
||||
data: {
|
||||
totalScore: {
|
||||
partyPlayed: {
|
||||
increment: score,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ Resource ./auth.resource
|
||||
*** Test Cases ***
|
||||
LoginAsGuest
|
||||
[Documentation] Login as a guest
|
||||
&{res}= POST /auth/guest
|
||||
&{res}= POST /auth/guest {"username": "i-am-a-guest"}
|
||||
Output
|
||||
Integer response status 200
|
||||
String response body access_token
|
||||
@@ -20,12 +20,13 @@ LoginAsGuest
|
||||
Integer response status 200
|
||||
Boolean response body isGuest true
|
||||
Integer response body partyPlayed 0
|
||||
String response body username "i-am-a-guest"
|
||||
|
||||
[Teardown] DELETE /auth/me
|
||||
|
||||
TwoGuests
|
||||
[Documentation] Login as a guest
|
||||
&{res}= POST /auth/guest
|
||||
&{res}= POST /auth/guest {"username": "i-am-another-guest"}
|
||||
Output
|
||||
Integer response status 200
|
||||
String response body access_token
|
||||
@@ -36,8 +37,9 @@ TwoGuests
|
||||
Integer response status 200
|
||||
Boolean response body isGuest true
|
||||
Integer response body partyPlayed 0
|
||||
String response body username "i-am-another-guest"
|
||||
|
||||
&{res2}= POST /auth/guest
|
||||
&{res2}= POST /auth/guest {"username": "i-am-a-third-guest"}
|
||||
Output
|
||||
Integer response status 200
|
||||
String response body access_token
|
||||
@@ -48,6 +50,7 @@ TwoGuests
|
||||
Integer response status 200
|
||||
Boolean response body isGuest true
|
||||
Integer response body partyPlayed 0
|
||||
String response body username "i-am-a-third-guest"
|
||||
|
||||
[Teardown] Run Keywords DELETE /auth/me
|
||||
... AND Set Headers {"Authorization": "Bearer ${res.body.access_token}"}
|
||||
@@ -55,7 +58,7 @@ TwoGuests
|
||||
|
||||
GuestToNormal
|
||||
[Documentation] Login as a guest and convert to a normal account
|
||||
&{res}= POST /auth/guest
|
||||
&{res}= POST /auth/guest {"username": "i-will-be-a-real-user"}
|
||||
Output
|
||||
Integer response status 200
|
||||
String response body access_token
|
||||
@@ -65,11 +68,13 @@ GuestToNormal
|
||||
Output
|
||||
Integer response status 200
|
||||
Boolean response body isGuest true
|
||||
String response body username "i-will-be-a-real-user"
|
||||
|
||||
${res}= PUT /auth/me { "username": "toto", "password": "toto", "email": "awdaw@b.c"}
|
||||
${res}= PUT /auth/me { "password": "toto", "email": "awdaw@b.c"}
|
||||
Output
|
||||
Integer response status 200
|
||||
String response body username "toto"
|
||||
Boolean response body isGuest false
|
||||
String response body username "i-will-be-a-real-user"
|
||||
|
||||
[Teardown] DELETE /auth/me
|
||||
|
||||
@@ -5,6 +5,7 @@ volumes:
|
||||
scoro_logs:
|
||||
meilisearch:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
#platform: linux/amd64
|
||||
@@ -21,7 +22,7 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
@@ -66,7 +67,6 @@ services:
|
||||
- NGINX_PORT=4567
|
||||
ports:
|
||||
- "19006:19006"
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./front:/app
|
||||
depends_on:
|
||||
@@ -92,14 +92,15 @@ services:
|
||||
- "4567:4567"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
image: getmeili/meilisearch:v1.4
|
||||
volumes:
|
||||
- meilisearch:/meili_data
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
|
||||
4
front/.expo-shared/assets.json
Normal file
4
front/.expo-shared/assets.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
111
front/API.ts
111
front/API.ts
@@ -1,4 +1,5 @@
|
||||
import Artist, { ArtistHandler } from './models/Artist';
|
||||
import Album from './models/Album';
|
||||
import Chapter from './models/Chapter';
|
||||
import Lesson from './models/Lesson';
|
||||
import Genre, { GenreHandler } from './models/Genre';
|
||||
@@ -23,7 +24,6 @@ import * as yup from 'yup';
|
||||
import { base64ToBlob } from './utils/base64ToBlob';
|
||||
import { ImagePickerAsset } from 'expo-image-picker';
|
||||
import { SongCursorInfos, SongCursorInfosHandler } from './models/SongCursorInfos';
|
||||
import { searchProps } from './views/V2/SearchView';
|
||||
|
||||
type AuthenticationInput = { username: string; password: string };
|
||||
type RegistrationInput = AuthenticationInput & { email: string };
|
||||
@@ -187,12 +187,12 @@ export default class API {
|
||||
});
|
||||
}
|
||||
|
||||
public static async createAndGetGuestAccount(): Promise<AccessToken> {
|
||||
public static async createAndGetGuestAccount(username: string): Promise<AccessToken> {
|
||||
return API.fetch(
|
||||
{
|
||||
route: '/auth/guest',
|
||||
method: 'POST',
|
||||
body: undefined,
|
||||
body: { username },
|
||||
},
|
||||
{ handler: AccessTokenResponseHandler }
|
||||
)
|
||||
@@ -497,6 +497,84 @@ export default class API {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a song by its name
|
||||
* @param query the string used to find the songs
|
||||
*/
|
||||
public static searchSongs(query: string): Query<Song[]> {
|
||||
return {
|
||||
key: ['search', 'song', query],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/search/songs/${query}`,
|
||||
},
|
||||
{ handler: ListHandler(SongHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search artists by name
|
||||
* @param query the string used to find the artists
|
||||
*/
|
||||
public static searchArtists(query: string): Query<Artist[]> {
|
||||
return {
|
||||
key: ['search', 'artist', query],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/search/artists/${query}`,
|
||||
},
|
||||
{ handler: ListHandler(ArtistHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Album by name
|
||||
* @param query the string used to find the album
|
||||
*/
|
||||
public static searchAlbum(query: string): Query<Album[]> {
|
||||
return {
|
||||
key: ['search', 'album', query],
|
||||
exec: async () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Super Trooper',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Kingdom Heart 365/2 OST',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'The Legend Of Zelda Ocarina Of Time OST',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Random Access Memories',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve music genres
|
||||
*/
|
||||
public static searchGenres(query: string): Query<Genre[]> {
|
||||
return {
|
||||
key: ['search', 'genre', query],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: `/search/genres/${query}`,
|
||||
},
|
||||
{ handler: ListHandler(GenreHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a lesson
|
||||
* @param lessonId the id to find the lesson
|
||||
@@ -701,31 +779,4 @@ export default class API {
|
||||
public static getPartitionSvgUrl(songId: number): string {
|
||||
return `${API.baseUrl}/song/${songId}/assets/partition`;
|
||||
}
|
||||
|
||||
public static searchSongs(query: searchProps, include?: SongInclude[]): Query<Song[]> {
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (query.query) queryParams.push(`q=${encodeURIComponent(query.query)}`);
|
||||
if (query.artist) queryParams.push(`artistId=${query.artist}`);
|
||||
if (query.genre) queryParams.push(`genreId=${query.genre}`);
|
||||
if (include) queryParams.push(`include=${include.join(',')}`);
|
||||
|
||||
const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
|
||||
|
||||
return {
|
||||
key: ['search', query.query, query.artist, query.genre, include],
|
||||
exec: () => {
|
||||
return API.fetch(
|
||||
{
|
||||
route: `/search/songs${queryString}`,
|
||||
},
|
||||
{ handler: ListHandler(SongHandler) }
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static getPartitionMelodyUrl(songId: number): string {
|
||||
return `${API.baseUrl}/song/${songId}/assets/melody`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ WORKDIR /app
|
||||
RUN yarn global add npx expo-cli
|
||||
|
||||
ENV DEVAPIURL http://back:3000
|
||||
CMD npx expo start --web
|
||||
CMD npx expo start --web
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/ban-types */
|
||||
import { NativeStackScreenProps, createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import {
|
||||
NativeStackNavigationProp,
|
||||
NativeStackScreenProps,
|
||||
createNativeStackNavigator,
|
||||
} from '@react-navigation/native-stack';
|
||||
import { ParamListBase, useNavigation as navigationHook } from '@react-navigation/native';
|
||||
import React, { ComponentProps, ComponentType, useEffect, useMemo } from 'react';
|
||||
NavigationProp,
|
||||
ParamListBase,
|
||||
useNavigation as navigationHook,
|
||||
} from '@react-navigation/native';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native';
|
||||
import { RootState, useSelector } from './state/Store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -33,201 +33,153 @@ import ForgotPasswordView from './views/ForgotPasswordView';
|
||||
import DiscoveryView from './views/V2/DiscoveryView';
|
||||
import MusicView from './views/MusicView';
|
||||
import Leaderboardiew from './views/LeaderboardView';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { createCustomNavigator } from './utils/navigator';
|
||||
import { Cup, Discover, Music, SearchNormal1, Setting2, User } from 'iconsax-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const Stack = createNativeStackNavigator<AppRouteParams>();
|
||||
const Tab = createCustomNavigator<AppRouteParams>();
|
||||
|
||||
const Tabs = () => {
|
||||
return (
|
||||
<Tab.Navigator>
|
||||
{Object.entries(tabRoutes).map(([name, route], routeIndex) => (
|
||||
<Tab.Screen
|
||||
key={'route-' + routeIndex}
|
||||
name={name as keyof AppRouteParams}
|
||||
options={{ ...route.options, headerTransparent: true }}
|
||||
component={route.component}
|
||||
/>
|
||||
))}
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
// Util function to hide route props in URL
|
||||
const removeMe = () => '';
|
||||
|
||||
const tabRoutes = {
|
||||
Home: {
|
||||
component: DiscoveryView,
|
||||
options: { headerShown: false, tabBarIcon: Discover },
|
||||
link: '/',
|
||||
},
|
||||
User: {
|
||||
component: ProfileView,
|
||||
options: { headerShown: false, tabBarIcon: User },
|
||||
link: '/user',
|
||||
},
|
||||
Music: {
|
||||
component: MusicView,
|
||||
options: { headerShown: false, tabBarIcon: Music },
|
||||
link: '/music',
|
||||
},
|
||||
Search: {
|
||||
component: SearchView,
|
||||
options: { headerShown: false, tabBarIcon: SearchNormal1 },
|
||||
link: '/search/:query?',
|
||||
},
|
||||
Leaderboard: {
|
||||
component: Leaderboardiew,
|
||||
options: { title: translate('leaderboardTitle'), headerShown: false, tabBarIcon: Cup },
|
||||
link: '/leaderboard',
|
||||
},
|
||||
Settings: {
|
||||
component: SettingsTab,
|
||||
options: { headerShown: false, tabBarIcon: Setting2, subMenu: true },
|
||||
link: '/settings/:screen?',
|
||||
stringify: {
|
||||
screen: removeMe,
|
||||
const protectedRoutes = () =>
|
||||
({
|
||||
Home: {
|
||||
component: DiscoveryView,
|
||||
options: { headerShown: false },
|
||||
link: '/',
|
||||
},
|
||||
},
|
||||
};
|
||||
Music: {
|
||||
component: MusicView,
|
||||
options: { headerShown: false },
|
||||
link: '/music',
|
||||
},
|
||||
Play: {
|
||||
component: PlayView,
|
||||
options: { headerShown: false, title: translate('play') },
|
||||
link: '/play/:songId',
|
||||
},
|
||||
Settings: {
|
||||
component: SettingsTab,
|
||||
options: { headerShown: false },
|
||||
link: '/settings/:screen?',
|
||||
stringify: {
|
||||
screen: removeMe,
|
||||
},
|
||||
},
|
||||
Artist: {
|
||||
component: ArtistDetailsView,
|
||||
options: { title: translate('artistFilter') },
|
||||
link: '/artist/:artistId',
|
||||
},
|
||||
Genre: {
|
||||
component: GenreDetailsView,
|
||||
options: { title: translate('genreFilter') },
|
||||
link: '/genre/:genreId',
|
||||
},
|
||||
Search: {
|
||||
component: SearchView,
|
||||
options: { headerShown: false },
|
||||
link: '/search/:query?',
|
||||
},
|
||||
Leaderboard: {
|
||||
component: Leaderboardiew,
|
||||
options: { title: translate('leaderboardTitle'), headerShown: false },
|
||||
link: '/leaderboard',
|
||||
},
|
||||
Error: {
|
||||
component: ErrorView,
|
||||
options: { title: translate('error'), headerLeft: null },
|
||||
link: undefined,
|
||||
},
|
||||
User: { component: ProfileView, options: { headerShown: false }, link: '/user' },
|
||||
Verified: {
|
||||
component: VerifiedView,
|
||||
options: { title: 'Verify email', headerShown: false },
|
||||
link: '/verify',
|
||||
},
|
||||
}) as const;
|
||||
|
||||
const protectedRoutes = {
|
||||
Tabs: {
|
||||
component: Tabs,
|
||||
options: { headerShown: false, path: '' },
|
||||
link: '',
|
||||
childRoutes: tabRoutes,
|
||||
},
|
||||
Play: {
|
||||
component: PlayView,
|
||||
options: { headerShown: false, title: translate('play') },
|
||||
link: '/play/:songId',
|
||||
},
|
||||
Artist: {
|
||||
component: ArtistDetailsView,
|
||||
options: { title: translate('artistFilter') },
|
||||
link: '/artist/:artistId',
|
||||
},
|
||||
Genre: {
|
||||
component: GenreDetailsView,
|
||||
options: { title: translate('genreFilter') },
|
||||
link: '/genre/:genreId',
|
||||
},
|
||||
Error: {
|
||||
component: ErrorView,
|
||||
options: { title: translate('error'), headerLeft: null },
|
||||
link: undefined,
|
||||
},
|
||||
Verified: {
|
||||
component: VerifiedView,
|
||||
options: { title: 'Verify email', headerShown: false },
|
||||
link: '/verify',
|
||||
},
|
||||
};
|
||||
|
||||
const publicRoutes = {
|
||||
Login: {
|
||||
component: SigninView,
|
||||
options: { title: translate('signInBtn'), headerShown: false },
|
||||
link: '/login',
|
||||
},
|
||||
Signup: {
|
||||
component: SignupView,
|
||||
options: { title: translate('signUpBtn'), headerShown: false },
|
||||
link: '/signup',
|
||||
},
|
||||
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',
|
||||
},
|
||||
};
|
||||
const publicRoutes = () =>
|
||||
({
|
||||
Login: {
|
||||
component: SigninView,
|
||||
options: { title: translate('signInBtn'), headerShown: false },
|
||||
link: '/login',
|
||||
},
|
||||
Signup: {
|
||||
component: SignupView,
|
||||
options: { title: translate('signUpBtn'), headerShown: false },
|
||||
link: '/signup',
|
||||
},
|
||||
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
|
||||
type Route<Props = any> = {
|
||||
component: ComponentType<Props>;
|
||||
component: (arg: RouteProps<Props>) => JSX.Element | (() => JSX.Element);
|
||||
options: object;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
// if the component has no props, ComponentProps return unknown so we remove those
|
||||
type RemoveNonObjects<T> = [T] extends [{}] ? T : undefined;
|
||||
type OmitOrUndefined<T, K extends string> = T extends undefined ? T : Omit<T, K>;
|
||||
|
||||
type RouteParams<Routes extends Record<string, Route>> = {
|
||||
[RouteName in keyof Routes]: RemoveNonObjects<ComponentProps<Routes[RouteName]['component']>>;
|
||||
[RouteName in keyof Routes]: OmitOrUndefined<
|
||||
Parameters<Routes[RouteName]['component']>[0],
|
||||
keyof NativeStackScreenProps<{}>
|
||||
>;
|
||||
};
|
||||
|
||||
type PrivateRoutesParams = RouteParams<typeof protectedRoutes>;
|
||||
type PublicRoutesParams = RouteParams<typeof publicRoutes>;
|
||||
type TabsRoutesParams = RouteParams<typeof tabRoutes>;
|
||||
type AppRouteParams = Omit<PrivateRoutesParams, 'Tabs'> & {
|
||||
Tabs: { screen: keyof TabsRoutesParams };
|
||||
} & PublicRoutesParams &
|
||||
TabsRoutesParams & { Oops: undefined };
|
||||
type PrivateRoutesParams = RouteParams<ReturnType<typeof protectedRoutes>>;
|
||||
type PublicRoutesParams = RouteParams<ReturnType<typeof publicRoutes>>;
|
||||
type AppRouteParams = PrivateRoutesParams & PublicRoutesParams;
|
||||
|
||||
const RouteToScreen = <T extends {}>(Component: Route<T>['component']) =>
|
||||
function Route(props: NativeStackScreenProps<T & ParamListBase>) {
|
||||
const colorScheme = useColorScheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never; Oops: never }>();
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={colorScheme === 'dark' ? ['#101014', '#6075F9'] : ['#cdd4fd', '#cdd4fd']}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<Component {...(props.route.params as T)} route={props.route} />
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
const RouteToScreen =
|
||||
<T extends {}>(component: Route<T>['component']) =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
(props: NativeStackScreenProps<T & ParamListBase>) => (
|
||||
<>
|
||||
{component({ ...props.route.params, route: props.route } as Parameters<
|
||||
Route<T>['component']
|
||||
>[0])}
|
||||
</>
|
||||
);
|
||||
|
||||
const routesToScreens = (routes: Partial<Record<keyof AppRouteParams, Route>>) =>
|
||||
Object.entries(routes).map(([name, route], routeIndex) => (
|
||||
<Stack.Screen
|
||||
key={'route-' + routeIndex}
|
||||
name={name as keyof AppRouteParams}
|
||||
options={{ ...route.options, headerTransparent: true }}
|
||||
options={route.options}
|
||||
component={RouteToScreen(route.component)}
|
||||
/>
|
||||
));
|
||||
|
||||
type RouteDescription = Record<
|
||||
string,
|
||||
{ link?: string; stringify?: Record<string, () => string>; childRoutes?: RouteDescription }
|
||||
>;
|
||||
|
||||
const routesToLinkingConfig = (routes: RouteDescription) => {
|
||||
const routesToLinkingConfig = (
|
||||
routes: Partial<
|
||||
Record<keyof AppRouteParams, { link?: string; stringify?: Record<string, () => string> }>
|
||||
>
|
||||
) => {
|
||||
// Too lazy to (find the) type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pagesToRoute = {} as Record<keyof AppRouteParams, any>;
|
||||
Object.keys(routes).forEach((route) => {
|
||||
const index = route as keyof AppRouteParams;
|
||||
if (routes[index]?.link !== undefined) {
|
||||
if (routes[index]?.link) {
|
||||
pagesToRoute[index] = {
|
||||
path: routes[index]!.link!,
|
||||
stringify: routes[index]!.stringify,
|
||||
screens: routes[index]!.childRoutes
|
||||
? routesToLinkingConfig(routes[index]!.childRoutes!).config.screens
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -287,6 +239,12 @@ export const Router = () => {
|
||||
}
|
||||
return 'noAuth';
|
||||
}, [userProfile, accessToken]);
|
||||
const routes = useMemo(() => {
|
||||
if (authStatus == 'authed') {
|
||||
return protectedRoutes();
|
||||
}
|
||||
return publicRoutes();
|
||||
}, [authStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
@@ -299,14 +257,13 @@ export const Router = () => {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
const routes = authStatus == 'authed' ? { ...protectedRoutes } : publicRoutes;
|
||||
return (
|
||||
<NavigationContainer
|
||||
linking={routesToLinkingConfig(routes)}
|
||||
fallback={<LoadingView />}
|
||||
theme={colorScheme == 'light' ? DefaultTheme : DarkTheme}
|
||||
>
|
||||
<Stack.Navigator screenOptions={{ navigationBarColor: 'transparent' }}>
|
||||
<Stack.Navigator>
|
||||
{authStatus == 'error' ? (
|
||||
<>
|
||||
<Stack.Screen
|
||||
@@ -315,7 +272,7 @@ export const Router = () => {
|
||||
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
|
||||
))}
|
||||
/>
|
||||
{routesToScreens(publicRoutes)}
|
||||
{routesToScreens(publicRoutes())}
|
||||
</>
|
||||
) : (
|
||||
routesToScreens(routes)
|
||||
@@ -325,4 +282,6 @@ export const Router = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const useNavigation = () => navigationHook<NativeStackNavigationProp<AppRouteParams>>();
|
||||
export type RouteProps<T> = T & Pick<NativeStackScreenProps<T & ParamListBase>, 'route'>;
|
||||
|
||||
export const useNavigation = () => navigationHook<NavigationProp<AppRouteParams>>();
|
||||
|
||||
@@ -30,8 +30,9 @@ const phoneLightGlassmorphism = {
|
||||
900: 'rgb(248, 250, 254)',
|
||||
1000: 'rgb(252, 254, 254)',
|
||||
};
|
||||
|
||||
const defaultDarkGlassmorphism = {
|
||||
const lightGlassmorphism =
|
||||
Platform.OS === 'web' ? defaultLightGlassmorphism : phoneLightGlassmorphism;
|
||||
const darkGlassmorphism = {
|
||||
50: 'rgba(16,16,20,0.9)',
|
||||
100: 'rgba(16,16,20,0.1)',
|
||||
200: 'rgba(16,16,20,0.2)',
|
||||
@@ -45,24 +46,6 @@ const defaultDarkGlassmorphism = {
|
||||
1000: 'rgba(16,16,20,1)',
|
||||
};
|
||||
|
||||
const phoneDarkGlassmorphism = {
|
||||
50: 'rgb(10, 14, 38)',
|
||||
100: 'rgb(14, 18, 42)',
|
||||
200: 'rgb(18, 22, 46)',
|
||||
300: 'rgb(22, 26, 50)',
|
||||
400: 'rgb(26, 30, 54)',
|
||||
500: 'rgb(10, 20, 54)',
|
||||
600: 'rgb(14, 24, 58)',
|
||||
700: 'rgb(18, 28, 62)',
|
||||
800: 'rgb(22, 32, 66)',
|
||||
900: 'rgb(26, 36, 70)',
|
||||
1000: 'rgb(30, 40, 74)',
|
||||
};
|
||||
|
||||
const lightGlassmorphism =
|
||||
Platform.OS === 'web' ? defaultLightGlassmorphism : phoneLightGlassmorphism;
|
||||
const darkGlassmorphism = Platform.OS === 'web' ? defaultDarkGlassmorphism : phoneDarkGlassmorphism;
|
||||
|
||||
const ThemeProvider = ({ children }: { children: JSX.Element }) => {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
|
||||
BIN
front/assets/piano/a0.mp3
Normal file
BIN
front/assets/piano/a0.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a1.mp3
Normal file
BIN
front/assets/piano/a1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a2.mp3
Normal file
BIN
front/assets/piano/a2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a3.mp3
Normal file
BIN
front/assets/piano/a3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a4.mp3
Normal file
BIN
front/assets/piano/a4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a5.mp3
Normal file
BIN
front/assets/piano/a5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a6.mp3
Normal file
BIN
front/assets/piano/a6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/a7.mp3
Normal file
BIN
front/assets/piano/a7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab1.mp3
Normal file
BIN
front/assets/piano/ab1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab2.mp3
Normal file
BIN
front/assets/piano/ab2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab3.mp3
Normal file
BIN
front/assets/piano/ab3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab4.mp3
Normal file
BIN
front/assets/piano/ab4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab5.mp3
Normal file
BIN
front/assets/piano/ab5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab6.mp3
Normal file
BIN
front/assets/piano/ab6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/ab7.mp3
Normal file
BIN
front/assets/piano/ab7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b0.mp3
Normal file
BIN
front/assets/piano/b0.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b1.mp3
Normal file
BIN
front/assets/piano/b1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b2.mp3
Normal file
BIN
front/assets/piano/b2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b3.mp3
Normal file
BIN
front/assets/piano/b3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b4.mp3
Normal file
BIN
front/assets/piano/b4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b5.mp3
Normal file
BIN
front/assets/piano/b5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b6.mp3
Normal file
BIN
front/assets/piano/b6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/b7.mp3
Normal file
BIN
front/assets/piano/b7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb0.mp3
Normal file
BIN
front/assets/piano/bb0.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb1.mp3
Normal file
BIN
front/assets/piano/bb1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb2.mp3
Normal file
BIN
front/assets/piano/bb2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb3.mp3
Normal file
BIN
front/assets/piano/bb3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb4.mp3
Normal file
BIN
front/assets/piano/bb4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb5.mp3
Normal file
BIN
front/assets/piano/bb5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb6.mp3
Normal file
BIN
front/assets/piano/bb6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/bb7.mp3
Normal file
BIN
front/assets/piano/bb7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c1.mp3
Normal file
BIN
front/assets/piano/c1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c2.mp3
Normal file
BIN
front/assets/piano/c2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c3.mp3
Normal file
BIN
front/assets/piano/c3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c4.mp3
Normal file
BIN
front/assets/piano/c4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c5.mp3
Normal file
BIN
front/assets/piano/c5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c6.mp3
Normal file
BIN
front/assets/piano/c6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c7.mp3
Normal file
BIN
front/assets/piano/c7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/c8.mp3
Normal file
BIN
front/assets/piano/c8.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d1.mp3
Normal file
BIN
front/assets/piano/d1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d2.mp3
Normal file
BIN
front/assets/piano/d2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d3.mp3
Normal file
BIN
front/assets/piano/d3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d4.mp3
Normal file
BIN
front/assets/piano/d4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d5.mp3
Normal file
BIN
front/assets/piano/d5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d6.mp3
Normal file
BIN
front/assets/piano/d6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/d7.mp3
Normal file
BIN
front/assets/piano/d7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db1.mp3
Normal file
BIN
front/assets/piano/db1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db2.mp3
Normal file
BIN
front/assets/piano/db2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db3.mp3
Normal file
BIN
front/assets/piano/db3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db4.mp3
Normal file
BIN
front/assets/piano/db4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db5.mp3
Normal file
BIN
front/assets/piano/db5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db6.mp3
Normal file
BIN
front/assets/piano/db6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/db7.mp3
Normal file
BIN
front/assets/piano/db7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e1.mp3
Normal file
BIN
front/assets/piano/e1.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e2.mp3
Normal file
BIN
front/assets/piano/e2.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e3.mp3
Normal file
BIN
front/assets/piano/e3.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e4.mp3
Normal file
BIN
front/assets/piano/e4.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e5.mp3
Normal file
BIN
front/assets/piano/e5.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e6.mp3
Normal file
BIN
front/assets/piano/e6.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/e7.mp3
Normal file
BIN
front/assets/piano/e7.mp3
Normal file
Binary file not shown.
BIN
front/assets/piano/eb1.mp3
Normal file
BIN
front/assets/piano/eb1.mp3
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user