Compare commits
142 Commits
front/play
...
feat/cover
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d427363f | ||
|
|
0b8b58e915 | ||
| bfb6cf5958 | |||
| a92ca75760 | |||
| 8d8323e382 | |||
| 76d7e69d19 | |||
| be58e932a9 | |||
| 38bbe56e9b | |||
| a65ce6595a | |||
|
|
90f7890e5f | ||
|
|
911e174aef | ||
|
|
6d7f46c425 | ||
|
|
b72e7a54e5 | ||
|
|
d99d134382 | ||
|
|
576675411a | ||
|
|
d214558bc4 | ||
|
|
4299a93afe | ||
|
|
920126a392 | ||
|
|
16e6a5e21b | ||
|
|
9539018b64 | ||
|
|
0081eb2acd | ||
|
|
bcb0825f5a | ||
|
|
18a3fa518c | ||
|
|
0407f5c29e | ||
|
|
6dafe2a8e9 | ||
|
|
4a8f0aa1af | ||
|
|
745b20358d | ||
|
|
0f544b31f3 | ||
|
|
76d70f3edd | ||
|
|
1c17ac8b13 | ||
|
|
232579e75b | ||
|
|
01221eda00 | ||
|
|
b73c2fef58 | ||
|
|
3c9c1b5ff7 | ||
|
|
e50b1c1344 | ||
|
|
6dfc531891 | ||
|
|
b4f268dee0 | ||
|
|
e366fa4b32 | ||
|
|
cd87451208 | ||
|
|
845c473ed5 | ||
|
|
f4d75eef73 | ||
|
|
5395bbb03a | ||
|
|
2d90c6eec1 | ||
|
|
0b0fd0585d | ||
|
|
6cf72dfcca | ||
|
|
a81c0b83bb | ||
|
|
b2fb497ecf | ||
| 445816dfad | |||
|
|
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 | ||
|
|
c21f5f0659 | ||
|
|
b43c64962a | ||
|
|
5cec62d1b1 | ||
|
|
c81f8df61c | ||
|
|
dc398d6e06 | ||
|
|
d5da112a01 | ||
|
|
96048bd671 | ||
|
|
dcdc6b196d | ||
|
|
2ec95dd3c3 | ||
|
|
5a190f3b96 |
@@ -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,6 +7,9 @@ JWT_SECRET=wow
|
||||
POSTGRES_DB=chromacase
|
||||
API_URL=http://localhost:80/api
|
||||
SCORO_URL=ws://localhost:6543
|
||||
MINIO_ROOT_PASSWORD=12345678
|
||||
EXPO_PUBLIC_API_URL=http://localhost:80/api
|
||||
EXPO_PUBLIC_SCORO_URL=ws://localhost:6543
|
||||
GOOGLE_CLIENT_ID=toto
|
||||
GOOGLE_SECRET=tata
|
||||
GOOGLE_CALLBACK_URL=http://localhost:19006/logged/google
|
||||
|
||||
35
.github/workflows/CI.yml
vendored
@@ -27,6 +27,25 @@ jobs:
|
||||
|
||||
## Build App ##
|
||||
|
||||
Check_Front:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
environment: Staging
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install Yarn
|
||||
run: npm install -g yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
- name: Type Check
|
||||
run: yarn tsc
|
||||
- name: Check Prettier
|
||||
run: yarn pretty:check .
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
|
||||
Build_Front:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
@@ -43,19 +62,21 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Type Check
|
||||
run: yarn tsc
|
||||
- name: Check Prettier
|
||||
run: yarn pretty:check .
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
|
||||
- name: 🏗 Setup Expo
|
||||
uses: expo/expo-github-action@v7
|
||||
with:
|
||||
expo-version: latest
|
||||
eas-version: 3.3.1
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- name: Build Web App
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: false
|
||||
tags: ${{steps.meta_front.outputs.tags}}
|
||||
build-args: |
|
||||
API_URL=${{secrets.API_URL}}
|
||||
SCORO_URL=${{secrets.SCORO_URL}}
|
||||
|
||||
- name: Build Android APK
|
||||
run: |
|
||||
|
||||
4
.gitignore
vendored
@@ -13,4 +13,6 @@ log.html
|
||||
node_modules/
|
||||
./front/coverage
|
||||
.venv
|
||||
.DS_Store
|
||||
.data
|
||||
.DS_Store
|
||||
_gen
|
||||
|
||||
38
README.md
@@ -1,9 +1,39 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
La principale raison pour laquelle on arrête de jouer d'un instrument est la perte de motivation. C'est un apprentissage long et vraiment demandant. ChromaCase propose d'accompagner les joueurs de piano grâce à une application mobile avec une expérience personnalisée. Celle-ci, générée par une IA, cible les goûts et identifie les difficultés du joueur.
|
||||
|
||||
Ça vous interesse? Rendez-vous sur notre [site](https://chromacase.studio/) pour prendre contact
|
||||
Ça vous interesse? Rendez-vous sur notre [site](http://eip.epitech.eu/2024/chromacase) pour prendre contact
|
||||
|
||||
## Structure du Projet
|
||||
## Comment lancer le projet
|
||||
|
||||

|
||||
Pensez à remplir un `.env` (à la racine du projet), en se basant sur le `.env.example`.
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Liens Utiles
|
||||
|
||||
- Site de Production: [Lien](http://chroma.octohub.app/)
|
||||
- Site du Nightly: [Lien](http://nightly.chroma.octohub.app/)
|
||||
- Site vitrine: [Lien](http://eip.epitech.eu/2024/chromacase)
|
||||
- Documentation: [Github](https://github.com/Chroma-Case/DAteX)
|
||||
|
||||
## Membres du Projet
|
||||
|
||||
| Nom | Role | Contact |
|
||||
|--------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| Zoé Roux | CEO, Responsable Back-end | [GitHub](https://github.com/zoriya) |
|
||||
| Clément Le-Bihan | CTO, Responsable Front-end | [GitHub](https://github.com/Octopus773) |
|
||||
| Arthur Jamet | Manager, Développeur Front-end | [GitHub](https://github.com/Arthi-chaud) |
|
||||
| Louis Auzuret | Développeur Back-end, Responsable CI | [Github](https://github.com/GitBluub) |
|
||||
| Aumaury Danis-Cousandier | Développeur Front-end | [Github](https://github.com/AmauryDanisCousandier) |
|
||||
| Mathys Paul | Développeur Front-end, Designer | [GitHub](https://github.com/mathysPaul) |
|
||||
|
||||
BIN
assets/graphical/banner.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
@@ -4,6 +4,7 @@ import sys
|
||||
import os
|
||||
import requests
|
||||
import glob
|
||||
from mido import MidiFile
|
||||
from configparser import ConfigParser
|
||||
|
||||
url = os.environ.get("API_URL")
|
||||
@@ -20,16 +21,16 @@ def getOrCreateAlbum(name, artistId):
|
||||
return out["id"]
|
||||
|
||||
def getOrCreateGenre(names):
|
||||
ids = []
|
||||
for name in names.split(","):
|
||||
res = requests.post(f"{url}/genre", json={
|
||||
"name": name,
|
||||
})
|
||||
out = res.json()
|
||||
print(out)
|
||||
ids += [out["id"]]
|
||||
#TODO handle multiple genres
|
||||
return ids[0]
|
||||
ids = []
|
||||
for name in names.split(","):
|
||||
res = requests.post(f"{url}/genre", json={
|
||||
"name": name,
|
||||
})
|
||||
out = res.json()
|
||||
print(out)
|
||||
ids += [out["id"]]
|
||||
#TODO handle multiple genres
|
||||
return ids[0]
|
||||
|
||||
def getOrCreateArtist(name):
|
||||
res = requests.post(f"{url}/artist", json={
|
||||
@@ -42,8 +43,10 @@ def getOrCreateArtist(name):
|
||||
def populateFile(path, midi, mxl):
|
||||
config = ConfigParser()
|
||||
config.read(path)
|
||||
mid = MidiFile(midi)
|
||||
metadata = config["Metadata"];
|
||||
difficulties = dict(config["Difficulties"])
|
||||
difficulties["length"] = round((mid.length), 2)
|
||||
artistId = getOrCreateArtist(metadata["Artist"])
|
||||
print(f"Populating {metadata['Name']}")
|
||||
res = requests.post(f"{url}/song", json={
|
||||
@@ -58,7 +61,6 @@ def populateFile(path, midi, mxl):
|
||||
})
|
||||
print(res.json())
|
||||
|
||||
|
||||
def main():
|
||||
global url
|
||||
if url == None:
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
back/artist.jpeg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
back/background_cover.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
back/icon_dark.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
12926
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,6 +21,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jimp/plugin-circle": "^0.22.10",
|
||||
"@nestjs-modules/mailer": "^1.9.1",
|
||||
"@nestjs/common": "^10.1.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
@@ -37,11 +38,14 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"jimp": "^0.22.10",
|
||||
"json-logger-service": "^9.0.1",
|
||||
"node-fetch": "^2.6.12",
|
||||
"nodemailer": "^6.9.5",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"prisma-class-generator": "^0.2.7",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -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")
|
||||
@@ -13,7 +19,7 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String?
|
||||
email String
|
||||
email String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
googleID String? @unique
|
||||
isGuest Boolean @default(false)
|
||||
@@ -22,6 +28,16 @@ model User {
|
||||
SongHistory SongHistory[]
|
||||
searchHistory SearchHistory[]
|
||||
settings UserSettings?
|
||||
likedSongs LikedSongs[]
|
||||
}
|
||||
|
||||
model LikedSongs {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
songId Int
|
||||
addedDate DateTime @default(now())
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
@@ -61,6 +77,7 @@ model Song {
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
difficulties Json
|
||||
SongHistory SongHistory[]
|
||||
likedByUsers LikedSongs[]
|
||||
}
|
||||
|
||||
model SongHistory {
|
||||
|
||||
BIN
back/radioart.jpeg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
back/radioart2.jpeg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
back/radioart3.jpeg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
back/radioart4.jpeg
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
@@ -12,23 +11,35 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} 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';
|
||||
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
|
||||
@Controller('album')
|
||||
@ApiTags('album')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AlbumController {
|
||||
static filterableFields: string[] = ['+id', 'name', '+artistId'];
|
||||
static includableFields: IncludeMap<Prisma.AlbumInclude> = {
|
||||
artist: true,
|
||||
Song: true,
|
||||
};
|
||||
|
||||
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 +56,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,10 +66,13 @@ export class AlbumController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Album)
|
||||
@ApiOperation({ description: 'Get all albums paginated' })
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(AlbumController.filterableFields)
|
||||
where: Prisma.AlbumWhereInput,
|
||||
@Query('include') include: string,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Album>> {
|
||||
@@ -65,13 +80,23 @@ export class AlbumController {
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, AlbumController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.albumService.album({ id });
|
||||
@ApiOperation({ description: 'Get an album by id' })
|
||||
@ApiOkResponse({ type: _Album })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query('include') include: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.albumService.album(
|
||||
{ id },
|
||||
mapInclude(include, req, AlbumController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Album not found');
|
||||
return res;
|
||||
|
||||
@@ -14,9 +14,11 @@ export class AlbumService {
|
||||
|
||||
async album(
|
||||
albumWhereUniqueInput: Prisma.AlbumWhereUniqueInput,
|
||||
include?: Prisma.AlbumInclude,
|
||||
): Promise<Album | null> {
|
||||
return this.prisma.album.findUnique({
|
||||
where: albumWhereUniqueInput,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,14 +28,16 @@ export class AlbumService {
|
||||
cursor?: Prisma.AlbumWhereUniqueInput;
|
||||
where?: Prisma.AlbumWhereInput;
|
||||
orderBy?: Prisma.AlbumOrderByWithRelationInput;
|
||||
include?: Prisma.AlbumInclude;
|
||||
}): Promise<Album[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.album.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
@@ -14,24 +13,42 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} 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';
|
||||
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { Public } from 'src/auth/public';
|
||||
|
||||
@Controller('artist')
|
||||
@ApiTags('artist')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ArtistController {
|
||||
static filterableFields = ['+id', 'name'];
|
||||
static includableFields: IncludeMap<Prisma.ArtistInclude> = {
|
||||
Song: true,
|
||||
Album: true,
|
||||
};
|
||||
|
||||
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 +58,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 +68,9 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
@ApiOperation({ description: "Get an artist's illustration" })
|
||||
@ApiNotFoundResponse({ description: 'Artist or illustration not found' })
|
||||
@Public()
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
const artist = await this.service.get({ id });
|
||||
if (!artist) throw new NotFoundException('Artist not found');
|
||||
@@ -66,10 +87,13 @@ export class ArtistController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ description: 'Get all artists paginated' })
|
||||
@ApiOkResponsePlaginated(_Artist)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(ArtistController.filterableFields)
|
||||
where: Prisma.ArtistWhereInput,
|
||||
@Query('include') include: string,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Artist>> {
|
||||
@@ -77,13 +101,23 @@ export class ArtistController {
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, ArtistController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.service.get({ id });
|
||||
@ApiOperation({ description: 'Get an artist by id' })
|
||||
@ApiOkResponse({ type: _Artist })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query('include') include: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.service.get(
|
||||
{ id },
|
||||
mapInclude(include, req, ArtistController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Artist not found');
|
||||
return res;
|
||||
|
||||
@@ -12,9 +12,13 @@ export class ArtistService {
|
||||
});
|
||||
}
|
||||
|
||||
async get(where: Prisma.ArtistWhereUniqueInput): Promise<Artist | null> {
|
||||
async get(
|
||||
where: Prisma.ArtistWhereUniqueInput,
|
||||
include?: Prisma.ArtistInclude,
|
||||
): Promise<Artist | null> {
|
||||
return this.prisma.artist.findUnique({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,14 +28,16 @@ export class ArtistService {
|
||||
cursor?: Prisma.ArtistWhereUniqueInput;
|
||||
where?: Prisma.ArtistWhereInput;
|
||||
orderBy?: Prisma.ArtistOrderByWithRelationInput;
|
||||
include?: Prisma.ArtistInclude;
|
||||
}): Promise<Artist[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.artist.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpCode,
|
||||
Put,
|
||||
InternalServerErrorException,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ParseFilePipeBuilder,
|
||||
Response,
|
||||
Query,
|
||||
Param,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
@@ -26,9 +28,12 @@ import { LocalAuthGuard } from './local-auth.guard';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConflictResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
@@ -42,6 +47,7 @@ import { SettingsService } from 'src/settings/settings.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { writeFile } from 'fs';
|
||||
import { PasswordResetDto } from './dto/password_reset.dto ';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -54,9 +60,14 @@ export class AuthController {
|
||||
|
||||
@Get('login/google')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@ApiOperation({ description: 'Redirect to google login page' })
|
||||
googleLogin() {}
|
||||
|
||||
@Get('logged/google')
|
||||
@ApiOperation({
|
||||
description:
|
||||
'Redirect to the front page after connecting to the google account',
|
||||
})
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleLoginCallbakc(@Req() req: any) {
|
||||
let user = await this.usersService.user({ googleID: req.user.googleID });
|
||||
@@ -68,43 +79,86 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ description: 'Register a new user' })
|
||||
@ApiConflictResponse({ description: 'Username or email already taken' })
|
||||
@ApiOkResponse({
|
||||
description: 'Successfully registered, email sent to verify',
|
||||
})
|
||||
@ApiBadRequestResponse({ description: 'Invalid data or database error' })
|
||||
async register(@Body() registerDto: RegisterDto): Promise<void> {
|
||||
try {
|
||||
const user = await this.usersService.createUser(registerDto);
|
||||
await this.settingsService.createUserSetting(user.id);
|
||||
await this.authService.sendVerifyMail(user);
|
||||
} catch (e) {
|
||||
// check if the error is a duplicate key error
|
||||
if (e.code === 'P2002') {
|
||||
throw new ConflictException('Username or email already taken');
|
||||
}
|
||||
console.error(e);
|
||||
throw new BadRequestException();
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('verify')
|
||||
async verify(@Request() req: any, @Query('token') token: string): Promise<void> {
|
||||
if (await this.authService.verifyMail(req.user.id, token))
|
||||
return;
|
||||
throw new BadRequestException("Invalid token. Expired or invalid.");
|
||||
@HttpCode(200)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@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)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('reverify')
|
||||
async reverify(@Request() req: any): Promise<void> {
|
||||
await this.authService.sendVerifyMail(req.user);
|
||||
@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);
|
||||
@@ -113,6 +167,7 @@ export class AuthController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ description: 'Get the profile picture of connected user' })
|
||||
@ApiOkResponse({ description: 'The user profile picture' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/picture')
|
||||
@@ -125,6 +180,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'The user profile picture' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Post('me/picture')
|
||||
@ApiOperation({ description: 'Upload a new profile picture' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async postProfilePicture(
|
||||
@Request() req: any,
|
||||
@@ -150,6 +206,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully logged in', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me')
|
||||
@ApiOperation({ description: 'Get the user info of connected user' })
|
||||
async getProfile(@Request() req: any): Promise<User> {
|
||||
const user = await this.usersService.user({ id: req.user.id });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
@@ -161,6 +218,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>,
|
||||
@@ -186,6 +244,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully deleted', type: User })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Delete('me')
|
||||
@ApiOperation({ description: 'Delete the profile of connected user' })
|
||||
deleteSelf(@Request() req: any): Promise<User> {
|
||||
return this.usersService.deleteUser({ id: req.user.id });
|
||||
}
|
||||
@@ -195,6 +254,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Patch('me/settings')
|
||||
@ApiOperation({ description: 'Edit the settings of connected user' })
|
||||
udpateSettings(
|
||||
@Request() req: any,
|
||||
@Body() settingUserDto: UpdateSettingDto,
|
||||
@@ -210,6 +270,7 @@ export class AuthController {
|
||||
@ApiOkResponse({ description: 'Successfully edited settings', type: Setting })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/settings')
|
||||
@ApiOperation({ description: 'Get the settings of connected user' })
|
||||
async getSettings(@Request() req: any): Promise<Setting> {
|
||||
const result = await this.settingsService.getUserSetting({
|
||||
userId: +req.user.id,
|
||||
@@ -217,4 +278,31 @@ export class AuthController {
|
||||
if (!result) throw new NotFoundException();
|
||||
return result;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully added liked song' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Post('me/likes/:id')
|
||||
addLikedSong(@Request() req: any, @Param('id') songId: number) {
|
||||
return this.usersService.addLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully removed liked song' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Delete('me/likes/:id')
|
||||
removeLikedSong(@Request() req: any, @Param('id') songId: number) {
|
||||
return this.usersService.removeLikedSong(+req.user.id, +songId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ description: 'Successfully retrieved liked song' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@Get('me/likes')
|
||||
getLikedSongs(@Request() req: any) {
|
||||
return this.usersService.getLikedSongs(+req.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { GoogleStrategy } from './google.strategy';
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '1h' },
|
||||
signOptions: { expiresIn: '365d' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
@@ -36,7 +36,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async sendVerifyMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === "true") return;
|
||||
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,
|
||||
@@ -47,15 +49,49 @@ export class AuthService {
|
||||
to: user.email,
|
||||
from: 'chromacase@octohub.app',
|
||||
subject: 'Mail verification for Chromacase',
|
||||
html: `To verify your mail, please click on this <a href="{${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
|
||||
html: `To verify your mail, please click on this <a href="${process.env.PUBLIC_URL}/verify?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordResetMail(user: User) {
|
||||
if (process.env.IGNORE_MAILS === 'true') return;
|
||||
if (user.email == null) return;
|
||||
console.log('Sending password reset mail to', user.email);
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{ expiresIn: '10h' },
|
||||
);
|
||||
await this.emailService.sendMail({
|
||||
to: user.email,
|
||||
from: 'chromacase@octohub.app',
|
||||
subject: 'Password reset for Chromacase',
|
||||
html: `To reset your password, please click on this <a href="${process.env.PUBLIC_URL}/password_reset?token=${token}">link</a>.`,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(new_password: string, token: string): Promise<boolean> {
|
||||
let verified;
|
||||
try {
|
||||
verified = await this.jwtService.verifyAsync(token);
|
||||
} catch (e) {
|
||||
console.log('Password reset token failure', e);
|
||||
return false;
|
||||
}
|
||||
console.log(verified);
|
||||
await this.userService.updateUser({
|
||||
where: { id: verified.userId },
|
||||
data: { password: new_password },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async verifyMail(userId: number, token: string): Promise<boolean> {
|
||||
try {
|
||||
await this.jwtService.verifyAsync(token);
|
||||
} catch(e) {
|
||||
console.log("Verify mail token failure", e);
|
||||
} catch (e) {
|
||||
console.log('Verify mail token failure', e);
|
||||
return false;
|
||||
}
|
||||
await this.userService.updateUser({
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from './public';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
console.log(context);
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
console.log(isPublic);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
4
back/src/auth/public.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} 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,11 +23,19 @@ 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';
|
||||
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { Public } from 'src/auth/public';
|
||||
|
||||
@Controller('genre')
|
||||
@ApiTags('genre')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GenreController {
|
||||
static filterableFields: string[] = ['+id', 'name'];
|
||||
static includableFields: IncludeMap<Prisma.GenreInclude> = {
|
||||
Song: true,
|
||||
};
|
||||
|
||||
constructor(private readonly service: GenreService) {}
|
||||
|
||||
@@ -49,6 +58,7 @@ export class GenreController {
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
@Public()
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
const genre = await this.service.get({ id });
|
||||
if (!genre) throw new NotFoundException('Genre not found');
|
||||
@@ -65,10 +75,12 @@ export class GenreController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Genre)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(GenreController.filterableFields)
|
||||
where: Prisma.GenreWhereInput,
|
||||
@Query('include') include: string,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Genre>> {
|
||||
@@ -76,13 +88,21 @@ export class GenreController {
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, GenreController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.service.get({ id });
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Query('include') include: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
const res = await this.service.get(
|
||||
{ id },
|
||||
mapInclude(include, req, GenreController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Genre not found');
|
||||
return res;
|
||||
|
||||
@@ -12,9 +12,13 @@ export class GenreService {
|
||||
});
|
||||
}
|
||||
|
||||
async get(where: Prisma.GenreWhereUniqueInput): Promise<Genre | null> {
|
||||
async get(
|
||||
where: Prisma.GenreWhereUniqueInput,
|
||||
include?: Prisma.GenreInclude,
|
||||
): Promise<Genre | null> {
|
||||
return this.prisma.genre.findUnique({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,14 +28,16 @@ export class GenreService {
|
||||
cursor?: Prisma.GenreWhereUniqueInput;
|
||||
where?: Prisma.GenreWhereInput;
|
||||
orderBy?: Prisma.GenreOrderByWithRelationInput;
|
||||
include?: Prisma.GenreInclude;
|
||||
}): Promise<Genre[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.genre.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,31 @@ 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 +46,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 +60,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: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Get,
|
||||
Query,
|
||||
Req,
|
||||
Request,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
@@ -12,12 +11,17 @@ import {
|
||||
Body,
|
||||
Delete,
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} 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';
|
||||
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
|
||||
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||
import { Request } from 'express';
|
||||
|
||||
export class Lesson {
|
||||
@ApiProperty()
|
||||
@@ -34,6 +38,7 @@ export class Lesson {
|
||||
|
||||
@ApiTags('lessons')
|
||||
@Controller('lesson')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LessonController {
|
||||
static filterableFields: string[] = [
|
||||
'+id',
|
||||
@@ -41,6 +46,9 @@ export class LessonController {
|
||||
'+requiredLevel',
|
||||
'mainSkill',
|
||||
];
|
||||
static includableFields: IncludeMap<Prisma.LessonInclude> = {
|
||||
LessonHistory: true,
|
||||
};
|
||||
|
||||
constructor(private lessonService: LessonService) {}
|
||||
|
||||
@@ -48,10 +56,12 @@ export class LessonController {
|
||||
summary: 'Get all lessons',
|
||||
})
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Lesson)
|
||||
async getAll(
|
||||
@Req() request: Request,
|
||||
@FilterQuery(LessonController.filterableFields)
|
||||
where: Prisma.LessonWhereInput,
|
||||
@Query('include') include: string,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Lesson>> {
|
||||
@@ -59,6 +69,7 @@ export class LessonController {
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, request, LessonController.includableFields),
|
||||
});
|
||||
return new Plage(ret, request);
|
||||
}
|
||||
@@ -67,8 +78,15 @@ export class LessonController {
|
||||
summary: 'Get a particular lessons',
|
||||
})
|
||||
@Get(':id')
|
||||
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
|
||||
const ret = await this.lessonService.get(id);
|
||||
async get(
|
||||
@Req() req: Request,
|
||||
@Query('include') include: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): Promise<Lesson> {
|
||||
const ret = await this.lessonService.get(
|
||||
id,
|
||||
mapInclude(include, req, LessonController.includableFields),
|
||||
);
|
||||
if (!ret) throw new NotFoundException();
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -12,22 +12,28 @@ export class LessonService {
|
||||
cursor?: Prisma.LessonWhereUniqueInput;
|
||||
where?: Prisma.LessonWhereInput;
|
||||
orderBy?: Prisma.LessonOrderByWithRelationInput;
|
||||
include?: Prisma.LessonInclude;
|
||||
}): Promise<Lesson[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.lesson.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: number): Promise<Lesson | null> {
|
||||
async get(
|
||||
id: number,
|
||||
include?: Prisma.LessonInclude,
|
||||
): Promise<Lesson | null> {
|
||||
return this.prisma.lesson.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { RequestLogger, RequestLoggerOptions } from 'json-logger-service';
|
||||
import { tap } from 'rxjs';
|
||||
import { PrismaModel } from './_gen/prisma-class';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AspectLogger implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const res = context.switchToHttp().getResponse();
|
||||
const { statusCode } = context.switchToHttp().getResponse();
|
||||
const { originalUrl, method, params, query, body, user } = req;
|
||||
|
||||
const toPrint = {
|
||||
originalUrl,
|
||||
method,
|
||||
params,
|
||||
query,
|
||||
body,
|
||||
userId: user?.id ?? 'not logged in',
|
||||
username: user?.username ?? 'not logged in',
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((/* data */) =>
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...toPrint,
|
||||
statusCode,
|
||||
//data, //TODO: Data crashed with images
|
||||
}),
|
||||
),),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.use(
|
||||
RequestLogger.buildExpressRequestLogger({
|
||||
doNotLogPaths: ['/health'],
|
||||
} as RequestLoggerOptions),
|
||||
);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
@@ -12,11 +58,15 @@ async function bootstrap() {
|
||||
.setDescription('The chromacase API')
|
||||
.setVersion('1.0')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
const document = SwaggerModule.createDocument(app, config, {
|
||||
extraModels: [...PrismaModel.extraModels],
|
||||
});
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.enableCors();
|
||||
app.useGlobalInterceptors(new AspectLogger());
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -2,16 +2,35 @@
|
||||
* 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 +68,25 @@ 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,33 +1,51 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
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';
|
||||
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')
|
||||
@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,
|
||||
@Query('include') include: string,
|
||||
@Param('query') query: string,
|
||||
): Promise<Song[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.songByGuess(query, req.user?.id);
|
||||
const ret = await this.searchService.songByGuess(
|
||||
query,
|
||||
req.user?.id,
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
@@ -37,9 +55,20 @@ export class SearchController {
|
||||
|
||||
@Get('genres/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchGenre(@Request() req: any, @Param('query') query: string): Promise<Genre[] | null> {
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@ApiOkResponse({ type: _Genre, isArray: true })
|
||||
@ApiOperation({ description: 'Search a genre' })
|
||||
async searchGenre(
|
||||
@Request() req: any,
|
||||
@Query('include') include: string,
|
||||
@Param('query') query: string,
|
||||
): Promise<Genre[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.genreByGuess(query, req.user?.id);
|
||||
const ret = await this.searchService.genreByGuess(
|
||||
query,
|
||||
req.user?.id,
|
||||
mapInclude(include, req, GenreController.includableFields),
|
||||
);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
@@ -49,13 +78,24 @@ export class SearchController {
|
||||
|
||||
@Get('artists/:query')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async searchArtists(@Request() req: any, @Param('query') query: string): Promise<Artist[] | null> {
|
||||
@ApiOkResponse({ type: _Artist, isArray: true })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
@ApiOperation({ description: 'Search an artist' })
|
||||
async searchArtists(
|
||||
@Request() req: any,
|
||||
@Query('include') include: string,
|
||||
@Param('query') query: string,
|
||||
): Promise<Artist[] | null> {
|
||||
try {
|
||||
const ret = await this.searchService.artistByGuess(query, req.user?.id);
|
||||
const ret = await this.searchService.artistByGuess(
|
||||
query,
|
||||
req.user?.id,
|
||||
mapInclude(include, req, ArtistController.includableFields),
|
||||
);
|
||||
if (!ret.length) throw new NotFoundException();
|
||||
else return ret;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Album, Artist, Prisma, Song, Genre } from '@prisma/client';
|
||||
import { Artist, Prisma, Song, Genre } from '@prisma/client';
|
||||
import { HistoryService } from 'src/history/history.service';
|
||||
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[]> {
|
||||
async songByGuess(
|
||||
query: string,
|
||||
userID: number,
|
||||
include?: Prisma.SongInclude,
|
||||
): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async genreByGuess(query: string, userID: number): Promise<Genre[]> {
|
||||
async genreByGuess(
|
||||
query: string,
|
||||
userID: number,
|
||||
include?: Prisma.GenreInclude,
|
||||
): Promise<Genre[]> {
|
||||
return this.prisma.genre.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
async artistByGuess(query: string, userID: number): Promise<Artist[]> {
|
||||
async artistByGuess(
|
||||
query: string,
|
||||
userID: number,
|
||||
include?: Prisma.ArtistInclude,
|
||||
): Promise<Artist[]> {
|
||||
return this.prisma.artist.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +16,42 @@ 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,
|
||||
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';
|
||||
import { IncludeMap, mapInclude } from 'src/utils/include';
|
||||
import { Public } from 'src/auth/public';
|
||||
import Jimp from 'jimp';
|
||||
|
||||
class SongHistoryResult {
|
||||
@ApiProperty()
|
||||
best: number;
|
||||
@ApiProperty({ type: SongHistory, isArray: true })
|
||||
history: SongHistory[];
|
||||
}
|
||||
|
||||
const BACKGROUND_COVER = 'radioart3.jpeg';
|
||||
const ICON = 'icon_dark.png';
|
||||
|
||||
@Controller('song')
|
||||
@ApiTags('song')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SongController {
|
||||
static filterableFields: string[] = [
|
||||
'+id',
|
||||
@@ -37,6 +60,13 @@ export class SongController {
|
||||
'+albumId',
|
||||
'+genreId',
|
||||
];
|
||||
static includableFields: IncludeMap<Prisma.SongInclude> = {
|
||||
artist: true,
|
||||
album: true,
|
||||
genre: true,
|
||||
SongHistory: ({ user }) => ({ where: { userID: user.id } }),
|
||||
likedByUsers: ({ user }) => ({ where: { userId: user.id } }),
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly songService: SongService,
|
||||
@@ -44,6 +74,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');
|
||||
@@ -56,15 +89,39 @@ export class SongController {
|
||||
}
|
||||
}
|
||||
|
||||
async gen_illustration(song: Song) {
|
||||
const img = await Jimp.read(BACKGROUND_COVER);
|
||||
// @ts-ignore
|
||||
const artist_img = await Jimp.read(`/assets/artists/${song.artist.name}/illustration.png`);
|
||||
const logo = await Jimp.read(ICON);
|
||||
img.cover(600, 600);
|
||||
artist_img.cover(400, 400);
|
||||
logo.cover(70, 70);
|
||||
artist_img.circle();
|
||||
img.composite(artist_img, 100, 100);
|
||||
img.composite(logo, 10, 10);
|
||||
return img;
|
||||
|
||||
}
|
||||
|
||||
@Get(':id/illustration')
|
||||
@ApiOperation({
|
||||
description: 'Streams the illustration of the requested song',
|
||||
})
|
||||
@ApiNotFoundResponse({ description: 'Song not found' })
|
||||
@ApiOkResponse({ description: 'Returns the illustration succesfully' })
|
||||
@Public()
|
||||
async getIllustration(@Param('id', ParseIntPipe) id: number) {
|
||||
const song = await this.songService.song({ id });
|
||||
|
||||
const song = await this.songService.song({ id }, { artist: true } );
|
||||
if (!song) throw new NotFoundException('Song not found');
|
||||
|
||||
//await this.gen_illustration(song);
|
||||
|
||||
if (song.illustrationPath === null) throw new NotFoundException();
|
||||
if (!existsSync(song.illustrationPath))
|
||||
throw new NotFoundException('Illustration not found');
|
||||
|
||||
if (!existsSync(song.illustrationPath)) {
|
||||
let img = await this.gen_illustration(song);
|
||||
img.write(song.illustrationPath);
|
||||
}
|
||||
try {
|
||||
const file = createReadStream(song.illustrationPath);
|
||||
return new StreamableFile(file);
|
||||
@@ -74,6 +131,11 @@ 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 +145,10 @@ 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({
|
||||
@@ -105,6 +171,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,9 +181,11 @@ export class SongController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponsePlaginated(_Song)
|
||||
async findAll(
|
||||
@Req() req: Request,
|
||||
@FilterQuery(SongController.filterableFields) where: Prisma.SongWhereInput,
|
||||
@Query('include') include: string,
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
|
||||
): Promise<Plage<Song>> {
|
||||
@@ -124,13 +193,26 @@ export class SongController {
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: mapInclude(include, req, SongController.includableFields),
|
||||
});
|
||||
return new Plage(ret, req);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
const res = await this.songService.song({ id });
|
||||
@ApiOperation({ description: 'Get a specific song data' })
|
||||
@ApiNotFoundResponse({ description: 'Song not found' })
|
||||
@ApiOkResponse({ type: _Song, description: 'Requested song' })
|
||||
async findOne(
|
||||
@Req() req: Request,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('include') include: string,
|
||||
) {
|
||||
const res = await this.songService.song(
|
||||
{
|
||||
id,
|
||||
},
|
||||
mapInclude(include, req, SongController.includableFields),
|
||||
);
|
||||
|
||||
if (res === null) throw new NotFoundException('Song not found');
|
||||
return res;
|
||||
@@ -138,7 +220,13 @@ 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({
|
||||
|
||||
@@ -9,7 +9,7 @@ export class SongService {
|
||||
async songByArtist(data: number): Promise<Song[]> {
|
||||
return this.prisma.song.findMany({
|
||||
where: {
|
||||
artistId: {equals: data},
|
||||
artistId: { equals: data },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,9 +22,11 @@ export class SongService {
|
||||
|
||||
async song(
|
||||
songWhereUniqueInput: Prisma.SongWhereUniqueInput,
|
||||
include?: Prisma.SongInclude,
|
||||
): Promise<Song | null> {
|
||||
return this.prisma.song.findUnique({
|
||||
where: songWhereUniqueInput,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,14 +36,16 @@ export class SongService {
|
||||
cursor?: Prisma.SongWhereUniqueInput;
|
||||
where?: Prisma.SongWhereInput;
|
||||
orderBy?: Prisma.SongOrderByWithRelationInput;
|
||||
include?: Prisma.SongInclude;
|
||||
}): Promise<Song[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
const { skip, take, cursor, where, orderBy, include } = params;
|
||||
return this.prisma.song.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Controller, Get, Param, NotFoundException, Response } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
NotFoundException,
|
||||
Response,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiNotFoundResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from 'src/models/user';
|
||||
|
||||
@ApiTags('users')
|
||||
@@ -22,6 +29,9 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Get(':id/picture')
|
||||
@ApiOkResponse({
|
||||
description: 'Return the profile picture of the requested user',
|
||||
})
|
||||
async getPicture(@Response() res: any, @Param('id') id: number) {
|
||||
return await this.usersService.getProfilePicture(+id, res);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
@@ -11,9 +12,7 @@ import fetch from 'node-fetch';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput,
|
||||
@@ -53,7 +52,7 @@ export class UsersService {
|
||||
username: `Guest ${randomUUID()}`,
|
||||
isGuest: true,
|
||||
// Not realyl clean but better than a separate table or breaking the api by adding nulls.
|
||||
email: '',
|
||||
email: null,
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
@@ -89,6 +88,7 @@ export class UsersService {
|
||||
// We could not find a profile icon locally, using gravatar instead.
|
||||
const user = await this.user({ id: userId });
|
||||
if (!user) throw new InternalServerErrorException();
|
||||
if (!user.email) throw new NotFoundException();
|
||||
const hash = createHash('md5')
|
||||
.update(user.email.trim().toLowerCase())
|
||||
.digest('hex');
|
||||
@@ -98,4 +98,22 @@ export class UsersService {
|
||||
for (const [k, v] of resp.headers) resp.headers.set(k, v);
|
||||
resp.body!.pipe(res);
|
||||
}
|
||||
|
||||
async addLikedSong(userId: number, songId: number) {
|
||||
return this.prisma.likedSongs.create({
|
||||
data: { songId: songId, userId: userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getLikedSongs(userId: number) {
|
||||
return this.prisma.likedSongs.findMany({
|
||||
where: { userId: userId },
|
||||
});
|
||||
}
|
||||
|
||||
async removeLikedSong(userId: number, songId: number) {
|
||||
return this.prisma.likedSongs.deleteMany({
|
||||
where: { userId: userId, songId: songId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
33
back/src/utils/include.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Request } from 'express';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export type IncludeMap<IncludeType> = {
|
||||
[key in keyof IncludeType]:
|
||||
| boolean
|
||||
| ((ctx: { user: { id: number; username: string } }) => IncludeType[key]);
|
||||
};
|
||||
|
||||
export function mapInclude<IncludeType>(
|
||||
include: string | undefined,
|
||||
req: Request,
|
||||
fields: IncludeMap<IncludeType>,
|
||||
): IncludeType | undefined {
|
||||
if (!include) return undefined;
|
||||
|
||||
const ret: IncludeType = {} as IncludeType;
|
||||
for (const key of include.split(',')) {
|
||||
const value =
|
||||
typeof fields[key] === 'function'
|
||||
? fields[key]({ user: req.user })
|
||||
: fields[key];
|
||||
if (value !== false && value !== undefined) ret[key] = value;
|
||||
else {
|
||||
throw new BadRequestException(
|
||||
`Invalid include, ${key} is not valid. Valid includes are: ${Object.keys(
|
||||
fields,
|
||||
).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Documentation Tests of the /song route.
|
||||
... Ensures that the songs CRUD works corectly.
|
||||
|
||||
Resource ../rest.resource
|
||||
Resource ../auth/auth.resource
|
||||
|
||||
|
||||
*** Test Cases ***
|
||||
@@ -133,5 +134,47 @@ Get midi file
|
||||
Integer response status 201
|
||||
GET /song/${res.body.id}/midi
|
||||
Integer response status 200
|
||||
#Output
|
||||
# Output
|
||||
[Teardown] DELETE /song/${res.body.id}
|
||||
|
||||
Find a song with artist
|
||||
[Documentation] Create a song and find it with it's artist
|
||||
&{res2}= POST /artist { "name": "Tghjmk"}
|
||||
Output
|
||||
Integer response status 201
|
||||
&{res}= POST
|
||||
... /song
|
||||
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
|
||||
Output
|
||||
Integer response status 201
|
||||
&{get}= GET /song/${res.body.id}?include=artist
|
||||
Output
|
||||
Integer response status 200
|
||||
Should Be Equal ${res2.body} ${get.body.artist}
|
||||
[Teardown] Run Keywords DELETE /song/${res.body.id}
|
||||
... AND DELETE /artist/${res2.body.id}
|
||||
|
||||
Find a song with artist and history
|
||||
[Documentation] Create a song and find it with it's artist
|
||||
${userID}= RegisterLogin wowusersfkj
|
||||
&{res2}= POST /artist { "name": "Tghjmk"}
|
||||
Output
|
||||
Integer response status 201
|
||||
&{res}= POST
|
||||
... /song
|
||||
... {"name": "Mama miaeyi", "artistId": ${res2.body.id}, "difficulties": {}, "midiPath": "/musics/Beethoven-125-4.midi", "musicXmlPath": "/musics/Beethoven-125-4.mxl"}
|
||||
Output
|
||||
Integer response status 201
|
||||
&{res3}= POST
|
||||
... /history
|
||||
... { "songID": ${res.body.id}, "userID": ${userID}, "score": 12, "difficulties": {}, "info": {} }
|
||||
Output
|
||||
Integer response status 201
|
||||
&{get}= GET /song/${res.body.id}?include=artist,SongHistory
|
||||
Output
|
||||
Integer response status 200
|
||||
Should Be Equal ${res2.body} ${get.body.artist}
|
||||
Should Be Equal ${res3.body} ${get.body.SongHistory[0]}
|
||||
[Teardown] Run Keywords DELETE /auth/me
|
||||
... AND DELETE /song/${res.body.id}
|
||||
... AND DELETE /artist/${res2.body.id}
|
||||
|
||||
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'
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
volumes:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build:
|
||||
@@ -25,6 +32,9 @@ services:
|
||||
volumes:
|
||||
- ./scorometer:/app
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
networks:
|
||||
- loki
|
||||
|
||||
db:
|
||||
container_name: db
|
||||
@@ -40,13 +50,14 @@ services:
|
||||
retries: 5
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
environment:
|
||||
- SCOROMETER_URL=http://scorometer:6543/
|
||||
- NGINX_PORT=80
|
||||
- NGINX_PORT=4567
|
||||
ports:
|
||||
- "19006:19006"
|
||||
volumes:
|
||||
@@ -55,3 +66,20 @@ services:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
environment:
|
||||
- API_URL=${API_URL:-http://back:3000}
|
||||
- SCOROMETER_URL=${SCOROMETER_URL:-http://scorometer:6543}
|
||||
- FRONT_URL=${FRONT_URL:-http://front:19006}
|
||||
- PORT=4567
|
||||
depends_on:
|
||||
- back
|
||||
- front
|
||||
- scorometer
|
||||
volumes:
|
||||
- "./front/assets:/assets:ro"
|
||||
- "./front/nginx.conf.template.dev:/etc/nginx/templates/default.conf.template:ro"
|
||||
ports:
|
||||
- "4567:4567"
|
||||
|
||||
185
docker-compose.log.yml
Normal file
@@ -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
|
||||
@@ -16,13 +22,14 @@ services:
|
||||
ports:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- scoro_logs:/logs
|
||||
- ./assets:/assets
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORDgrafana}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
@@ -43,4 +50,4 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
- .env
|
||||
@@ -1,3 +1,12 @@
|
||||
networks:
|
||||
loki:
|
||||
|
||||
|
||||
volumes:
|
||||
db:
|
||||
scoro_logs:
|
||||
|
||||
|
||||
services:
|
||||
back:
|
||||
build: ./back
|
||||
@@ -17,6 +26,7 @@ services:
|
||||
- "6543:6543"
|
||||
volumes:
|
||||
- ./assets:/assets
|
||||
- scoro_logs:/logs
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:alpine3.14
|
||||
@@ -35,11 +45,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
args:
|
||||
- API_URL=${API_URL}
|
||||
- SCORO_URL=${SCORO_URL}
|
||||
build: ./front
|
||||
environment:
|
||||
- API_URL=http://back:3000/
|
||||
- SCOROMETER_URL=http://scorometer:6543/
|
||||
@@ -49,7 +55,4 @@ services:
|
||||
depends_on:
|
||||
- "back"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
db:
|
||||
- .env
|
||||
@@ -1,4 +1,7 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
|
||||
31
front/.gitignore
vendored
@@ -1,18 +1,35 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
*.apk
|
||||
yarn.error*
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
.idea/
|
||||
.expo
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
47
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';
|
||||
@@ -66,9 +66,7 @@ export class ValidationError extends Error {
|
||||
|
||||
export default class API {
|
||||
public static readonly baseUrl =
|
||||
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
|
||||
? '/api'
|
||||
: Constants.manifest?.extra?.apiUrl;
|
||||
Platform.OS === 'web' ? '/api' : process.env.EXPO_PUBLIC_API_URL!;
|
||||
public static async fetch(
|
||||
params: FetchParams,
|
||||
handle: Pick<Required<HandleParams>, 'raw'>
|
||||
@@ -97,8 +95,16 @@ export default class API {
|
||||
});
|
||||
if (!handle || handle.emptyResponse) {
|
||||
if (!response.ok) {
|
||||
console.log(await response.json());
|
||||
throw new APIError(response.statusText, response.status);
|
||||
let responseMessage = response.statusText;
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
console.log(responseData);
|
||||
if (responseData.message) responseMessage = responseData.message;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new APIError(response.statusText, response.status, 'unknownError');
|
||||
}
|
||||
throw new APIError(responseMessage, response.status, 'unknownError');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -110,7 +116,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) {
|
||||
@@ -662,4 +668,31 @@ export default class API {
|
||||
formData,
|
||||
});
|
||||
}
|
||||
|
||||
public static async addLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
public static async removeLikedSong(songId: number): Promise<void> {
|
||||
await API.fetch({
|
||||
route: `/auth/me/likes/${songId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
public static getLikedSongs(): Query<likedSong[]> {
|
||||
return {
|
||||
key: ['liked songs'],
|
||||
exec: () =>
|
||||
API.fetch(
|
||||
{
|
||||
route: '/auth/me/likes',
|
||||
},
|
||||
{ handler: ListHandler(LikedSongHandler) }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -4,24 +4,24 @@
|
||||
FROM node:16-alpine as build
|
||||
WORKDIR /app
|
||||
# install expo cli
|
||||
RUN yarn global add expo-cli@6.0.5
|
||||
RUN yarn global add expo-cli
|
||||
# add sharp-cli (^2.1.0) for faster image processing
|
||||
RUN yarn global add sharp-cli@^2.1.0
|
||||
RUN yarn global add sharp-cli
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
RUN yarn install --immutable
|
||||
RUN expo install
|
||||
|
||||
COPY . .
|
||||
ARG API_URL
|
||||
ENV API_URL=$API_URL
|
||||
ENV EXPO_PUBLIC_API_URL=$API_URL
|
||||
ARG SCORO_URL
|
||||
ENV SCORO_URL=$SCORO_URL
|
||||
ENV EXPO_PUBLIC_API_URL=$SCORO_URL
|
||||
|
||||
RUN yarn tsc && expo build:web
|
||||
RUN yarn tsc && npx expo export: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';
|
||||
@@ -31,6 +30,11 @@ 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 = () => '';
|
||||
@@ -42,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,
|
||||
@@ -87,7 +96,7 @@ const protectedRoutes = () =>
|
||||
options: { title: 'Verify email', headerShown: false },
|
||||
link: '/verify',
|
||||
},
|
||||
} as const);
|
||||
}) as const;
|
||||
|
||||
const publicRoutes = () =>
|
||||
({
|
||||
@@ -97,28 +106,31 @@ 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: {
|
||||
component: ProfileErrorView,
|
||||
options: { title: 'Oops', headerShown: false },
|
||||
link: undefined,
|
||||
},
|
||||
Google: {
|
||||
component: GoogleView,
|
||||
options: { title: 'Google signin', headerShown: false },
|
||||
link: '/logged/google',
|
||||
},
|
||||
} as const);
|
||||
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> = {
|
||||
@@ -139,19 +151,18 @@ type PrivateRoutesParams = RouteParams<ReturnType<typeof protectedRoutes>>;
|
||||
type PublicRoutesParams = RouteParams<ReturnType<typeof publicRoutes>>;
|
||||
type AppRouteParams = PrivateRoutesParams & PublicRoutesParams;
|
||||
|
||||
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never }>();
|
||||
const Stack = createNativeStackNavigator<AppRouteParams & { Loading: never; Oops: never }>();
|
||||
|
||||
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])}
|
||||
</>
|
||||
);
|
||||
(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) => (
|
||||
@@ -188,6 +199,8 @@ const routesToLinkingConfig = (
|
||||
|
||||
const ProfileErrorView = (props: { onTryAgain: () => void }) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
<Center style={{ flexGrow: 1 }}>
|
||||
<VStack space={3}>
|
||||
@@ -196,7 +209,10 @@ const ProfileErrorView = (props: { onTryAgain: () => void }) => {
|
||||
<Translate translationKey="tryAgain" />
|
||||
</Button>
|
||||
<TextButton
|
||||
onPress={() => dispatch(unsetAccessToken())}
|
||||
onPress={() => {
|
||||
dispatch(unsetAccessToken());
|
||||
navigation.navigate('Start');
|
||||
}}
|
||||
colorScheme="error"
|
||||
variant="outline"
|
||||
translate={{ translationKey: 'signOutBtn' }}
|
||||
@@ -257,12 +273,15 @@ export const Router = () => {
|
||||
>
|
||||
<Stack.Navigator>
|
||||
{authStatus == 'error' ? (
|
||||
<Stack.Screen
|
||||
name="Oops"
|
||||
component={RouteToScreen(() => (
|
||||
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
|
||||
))}
|
||||
/>
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="Oops"
|
||||
component={RouteToScreen(() => (
|
||||
<ProfileErrorView onTryAgain={() => userProfile.refetch()} />
|
||||
))}
|
||||
/>
|
||||
{routesToScreens(publicRoutes())}
|
||||
</>
|
||||
) : (
|
||||
routesToScreens(routes)
|
||||
)}
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})}
|
||||
|
||||
15
front/android/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
180
front/android/app/build.gradle
Normal file
@@ -0,0 +1,180 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli')"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '..'
|
||||
// root = file("../")
|
||||
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
|
||||
// reactNativeDir = file("../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||
*/
|
||||
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'build.apk'
|
||||
defaultConfig {
|
||||
applicationId 'build.apk'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString())
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
def frescoVersion = rootProject.ext.frescoVersion
|
||||
|
||||
// If your app supports Android versions before Ice Cream Sandwich (API level 14)
|
||||
if (isGifEnabled || isWebpEnabled) {
|
||||
implementation("com.facebook.fresco:fresco:${frescoVersion}")
|
||||
implementation("com.facebook.fresco:imagepipeline-okhttp3:${frescoVersion}")
|
||||
}
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${frescoVersion}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${frescoVersion}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${frescoVersion}")
|
||||
}
|
||||
}
|
||||
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
|
||||
apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
|
||||
applyNativeModulesAppBuildGradle(project)
|
||||
BIN
front/android/app/debug.keystore
Normal file
14
front/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
7
front/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
33
front/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_SDK_VERSION" android:value="49.0.0"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="build.apk"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false"/>
|
||||
</application>
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
3
front/android/app/src/main/res/drawable/splashscreen.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
</layer-list>
|
||||
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
BIN
front/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
front/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
front/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
BIN
front/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
front/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
1
front/android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1 @@
|
||||
<resources/>
|
||||
6
front/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#FFFFFF</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#ffffff</color>
|
||||
</resources>
|
||||
5
front/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Chromacase</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
</resources>
|
||||