From 4ac6369deb308f3a9e6d4b184f973cadd2990995 Mon Sep 17 00:00:00 2001 From: Amaury <75248680+AmauryDanisCousandier@users.noreply.github.com> Date: Mon, 4 Dec 2023 23:37:06 +0100 Subject: [PATCH] Leaderboard View (#332) * LeaderboardView init * back scores handling * blah * add score controller * commit score on end of play * front and back fix * podium component * push the button * ill be baaack * flex css thing * pretty * migration leaderboard * feat(leaderboard): wip * feat(leaderboard): pretty * feat(leaderboard): i might be dumb * fix(leaderboard): misuse of nullable() for totalScore User validator --- .../migration.sql | 2 + back/prisma/schema.prisma | 1 + back/src/app.module.ts | 2 + back/src/auth/auth.controller.ts | 15 ++ back/src/models/user.ts | 2 + back/src/scores/scores.controller.ts | 19 ++ back/src/scores/scores.module.ts | 11 ++ back/src/scores/scores.service.ts | 19 ++ back/src/users/users.service.ts | 14 ++ front/API.ts | 21 +++ front/Navigation.tsx | 6 + front/components/UI/ScaffoldCC.tsx | 2 +- front/components/UserAvatar.tsx | 2 +- front/components/V2/TabNavigation.tsx | 12 -- front/components/V2/TabNavigationButton.tsx | 86 --------- front/components/V2/TabNavigationDesktop.tsx | 174 ------------------ front/components/V2/TabNavigationList.tsx | 28 --- front/components/V2/TabNavigationPhone.tsx | 71 ------- front/components/leaderboard/BoardRow.tsx | 102 ++++++++++ front/components/leaderboard/PodiumCard.tsx | 98 ++++++++++ front/i18n/Translations.ts | 18 ++ front/models/User.ts | 3 + front/views/HomeView.tsx | 6 + front/views/LeaderboardView.tsx | 174 ++++++++++++++++++ front/views/PlayView.tsx | 1 + 25 files changed, 516 insertions(+), 373 deletions(-) create mode 100644 back/prisma/migrations/20231106195751_add_total_score/migration.sql create mode 100644 back/src/scores/scores.controller.ts create mode 100644 back/src/scores/scores.module.ts create mode 100644 back/src/scores/scores.service.ts delete mode 100644 front/components/V2/TabNavigation.tsx delete mode 100644 front/components/V2/TabNavigationButton.tsx delete mode 100644 front/components/V2/TabNavigationDesktop.tsx delete mode 100644 front/components/V2/TabNavigationList.tsx delete mode 100644 front/components/V2/TabNavigationPhone.tsx create mode 100644 front/components/leaderboard/BoardRow.tsx create mode 100644 front/components/leaderboard/PodiumCard.tsx create mode 100644 front/views/LeaderboardView.tsx diff --git a/back/prisma/migrations/20231106195751_add_total_score/migration.sql b/back/prisma/migrations/20231106195751_add_total_score/migration.sql new file mode 100644 index 0000000..5771cc4 --- /dev/null +++ b/back/prisma/migrations/20231106195751_add_total_score/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "totalScore" INTEGER NOT NULL DEFAULT 0; diff --git a/back/prisma/schema.prisma b/back/prisma/schema.prisma index d15643f..7c29e65 100644 --- a/back/prisma/schema.prisma +++ b/back/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { googleID String? @unique isGuest Boolean @default(false) partyPlayed Int @default(0) + totalScore Int @default(0) LessonHistory LessonHistory[] SongHistory SongHistory[] searchHistory SearchHistory[] diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 52ba5e4..12bfe72 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -15,6 +15,7 @@ import { AlbumModule } from "./album/album.module"; import { SearchModule } from "./search/search.module"; import { HistoryModule } from "./history/history.module"; import { MailerModule } from "@nestjs-modules/mailer"; +import { ScoresModule } from "./scores/scores.module"; @Module({ imports: [ @@ -29,6 +30,7 @@ import { MailerModule } from "@nestjs-modules/mailer"; SearchModule, SettingsModule, HistoryModule, + ScoresModule, MailerModule.forRoot({ transport: process.env.SMTP_TRANSPORT, defaults: { diff --git a/back/src/auth/auth.controller.ts b/back/src/auth/auth.controller.ts index d4c5e92..11bb588 100644 --- a/back/src/auth/auth.controller.ts +++ b/back/src/auth/auth.controller.ts @@ -311,4 +311,19 @@ export class AuthController { mapInclude(include, req, SongController.includableFields), ); } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOkResponse({ description: 'Successfully added score'}) + @ApiUnauthorizedResponse({ description: 'Invalid token' }) + @Patch('me/score/:score') + addScore( + @Request() req: any, + @Param('id') score: number, + ) { + return this.usersService.addScore( + +req.user.id, + score, + ); + } } diff --git a/back/src/models/user.ts b/back/src/models/user.ts index 01d7d00..68b70ff 100644 --- a/back/src/models/user.ts +++ b/back/src/models/user.ts @@ -11,4 +11,6 @@ export class User { isGuest: boolean; @ApiProperty() partyPlayed: number; + @ApiProperty() + totalScore: number; } diff --git a/back/src/scores/scores.controller.ts b/back/src/scores/scores.controller.ts new file mode 100644 index 0000000..a833e11 --- /dev/null +++ b/back/src/scores/scores.controller.ts @@ -0,0 +1,19 @@ +import { + Controller, + Get, +} from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ScoresService } from './scores.service'; +import { User } from '@prisma/client'; + +@ApiTags('scores') +@Controller('scores') +export class ScoresController { + constructor(private readonly scoresService: ScoresService) {} + + @ApiOkResponse({ description: 'Successfully sent the Top 20 players'}) + @Get('top/20') + getTopTwenty(): Promise { + return this.scoresService.topTwenty(); + } +} \ No newline at end of file diff --git a/back/src/scores/scores.module.ts b/back/src/scores/scores.module.ts new file mode 100644 index 0000000..2ad0384 --- /dev/null +++ b/back/src/scores/scores.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScoresService } from './scores.service'; +import { ScoresController } from './scores.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ScoresController], + providers: [ScoresService], +}) +export class ScoresModule {} diff --git a/back/src/scores/scores.service.ts b/back/src/scores/scores.service.ts new file mode 100644 index 0000000..c48f229 --- /dev/null +++ b/back/src/scores/scores.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Injectable() +export class ScoresService { + constructor( + private prisma: PrismaService, + ) {} + + async topTwenty(): Promise { + return this.prisma.user.findMany({ + orderBy: { + totalScore: 'desc', + }, + take: 20, + }); + } +} \ No newline at end of file diff --git a/back/src/users/users.service.ts b/back/src/users/users.service.ts index 65baa83..22021b4 100644 --- a/back/src/users/users.service.ts +++ b/back/src/users/users.service.ts @@ -117,4 +117,18 @@ export class UsersService { where: { userId: userId, songId: songId }, }); } + + async addScore( + where: number, + score: number, + ) { + return this.prisma.user.update({ + where: { id: where }, + data: { + partyPlayed: { + increment: score, + }, + }, + }); + } } diff --git a/front/API.ts b/front/API.ts index f54b6dd..059ee1a 100644 --- a/front/API.ts +++ b/front/API.ts @@ -733,4 +733,25 @@ export default class API { public static getPartitionSvgUrl(songId: number): string { return `${API.baseUrl}/song/${songId}/assets/partition`; } + + public static async updateUserTotalScore(score: number): Promise { + await API.fetch({ + route: `/auth/me/score/${score}`, + method: 'PATCH', + }); + } + + public static getTopTwentyPlayers(): Query { + return { + key: ['score'], + exec: () => + API.fetch( + { + route: '/scores/top/20', + method: 'GET', + }, + { handler: ListHandler(UserHandler) } + ), + }; + } } diff --git a/front/Navigation.tsx b/front/Navigation.tsx index 8c4650e..2ab6ca8 100644 --- a/front/Navigation.tsx +++ b/front/Navigation.tsx @@ -33,6 +33,7 @@ import PasswordResetView from './views/PasswordResetView'; import ForgotPasswordView from './views/ForgotPasswordView'; import DiscoveryView from './views/V2/DiscoveryView'; import MusicView from './views/MusicView'; +import Leaderboardiew from './views/LeaderboardView'; // Util function to hide route props in URL const removeMe = () => ''; @@ -87,6 +88,11 @@ const protectedRoutes = () => options: { headerShown: false }, link: '/search/:query?', }, + Leaderboard: { + component: Leaderboardiew, + options: { title: translate('leaderboardTitle'), headerShown: false }, + link: '/leaderboard', + }, Error: { component: ErrorView, options: { title: translate('error'), headerLeft: null }, diff --git a/front/components/UI/ScaffoldCC.tsx b/front/components/UI/ScaffoldCC.tsx index c761200..c6070a0 100644 --- a/front/components/UI/ScaffoldCC.tsx +++ b/front/components/UI/ScaffoldCC.tsx @@ -13,7 +13,7 @@ const menu = [ { type: 'main', title: 'menuProfile', icon: User, link: 'User' }, { type: 'main', title: 'menuMusic', icon: Music, link: 'Music' }, { type: 'main', title: 'menuSearch', icon: SearchNormal1, link: 'Search' }, - { type: 'main', title: 'menuLeaderBoard', icon: Cup, link: 'Score' }, + { type: 'main', title: 'menuLeaderBoard', icon: Cup, link: 'Leaderboard' }, { type: 'sub', title: 'menuSettings', icon: Setting2, link: 'Settings' }, ] as const; diff --git a/front/components/UserAvatar.tsx b/front/components/UserAvatar.tsx index 85ce142..14037a0 100644 --- a/front/components/UserAvatar.tsx +++ b/front/components/UserAvatar.tsx @@ -3,7 +3,7 @@ import API from '../API'; import { useQuery } from '../Queries'; import { useMemo } from 'react'; -const getInitials = (name: string) => { +export const getInitials = (name: string) => { return name .split(' ') .map((n) => n[0]) diff --git a/front/components/V2/TabNavigation.tsx b/front/components/V2/TabNavigation.tsx deleted file mode 100644 index 2ba27e7..0000000 --- a/front/components/V2/TabNavigation.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -export type NaviTab = { - id: string; - label: string; - icon?: React.ReactNode; - onPress?: () => void; - onLongPress?: () => void; - isActive?: boolean; - isCollapsed?: boolean; - iconName?: string; -}; diff --git a/front/components/V2/TabNavigationButton.tsx b/front/components/V2/TabNavigationButton.tsx deleted file mode 100644 index d074553..0000000 --- a/front/components/V2/TabNavigationButton.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { View } from 'react-native'; -import { Pressable, Text } from 'native-base'; -import React from 'react'; - -type TabNavigationButtonProps = { - icon?: React.ReactNode; - label: string; - onPress: () => void; - onLongPress: () => void; - isActive: boolean; - isCollapsed: boolean; -}; - -const TabNavigationButton = (props: TabNavigationButtonProps) => { - return ( - - {({ isPressed, isHovered }) => { - let boxShadow: string | undefined = undefined; - if (isHovered) { - boxShadow = '0px 0px 16px 0px rgba(0, 0, 0, 0.25)'; - } else if (props.isActive) { - boxShadow = '0px 0px 8px 0px rgba(0, 0, 0, 0.25)'; - } - return ( - { - if (isPressed) { - return 'rgba(0, 0, 0, 0.1)'; - } else if (isHovered) { - return 'rgba(231, 231, 232, 0.2)'; - } else if (props.isActive) { - return 'rgba(16, 16, 20, 0.5)'; - } else { - return 'transparent'; - } - })(), - }} - > - {props.icon && ( - - {props.icon} - - )} - {!props.isCollapsed && ( - - {props.label} - - )} - - ); - }} - - ); -}; - -TabNavigationButton.defaultProps = { - icon: undefined, - onPress: () => {}, - onLongPress: () => {}, - isActive: false, - isCollapsed: false, -}; - -export default TabNavigationButton; diff --git a/front/components/V2/TabNavigationDesktop.tsx b/front/components/V2/TabNavigationDesktop.tsx deleted file mode 100644 index 62e85c7..0000000 --- a/front/components/V2/TabNavigationDesktop.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { View, Image } from 'react-native'; -import { Divider, Text, Center, ScrollView } from 'native-base'; -import TabNavigationButton from './TabNavigationButton'; -import TabNavigationList from './TabNavigationList'; -import { useAssets } from 'expo-asset'; -import useColorScheme from '../../hooks/colorScheme'; -import { useQuery } from '../../Queries'; -import { NaviTab } from './TabNavigation'; -import API from '../../API'; - -type TabNavigationDesktopProps = { - tabs: NaviTab[]; - isCollapsed: boolean; - setIsCollapsed: (isCollapsed: boolean) => void; - activeTabID: string; - setActiveTabID: (id: string) => void; - children?: React.ReactNode; -}; - -const TabNavigationDesktop = (props: TabNavigationDesktopProps) => { - const colorScheme = useColorScheme(); - const [icon] = useAssets( - colorScheme == 'light' - ? require('../../assets/icon_light.png') - : require('../../assets/icon_dark.png') - ); - const history = useQuery(API.getUserPlayHistory); - // settings is displayed separately (with logout) - const buttons = props.tabs.filter((tab) => tab.id !== 'settings'); - - return ( - - -
- - - - Chromacase - - -
- - - {buttons.map((button, index) => ( - - ))} - - - - - - Recently played - - {history.data?.length === 0 && ( - - No songs played yet - - )} - {history.data - ?.map((x) => x.song) - .filter( - (song, i, array) => - array.map((s) => s.id).findIndex((id) => id == song.id) == i - ) - .slice(0, 4) - .map((histoItem, index) => ( - - {histoItem.name} - - ))} - - - - {([props.tabs.find((t) => t.id === 'settings')] as NaviTab[]).map( - (button, index) => ( - - ) - )} - - - -
- - {props.children} - -
- ); -}; - -export default TabNavigationDesktop; diff --git a/front/components/V2/TabNavigationList.tsx b/front/components/V2/TabNavigationList.tsx deleted file mode 100644 index be94b14..0000000 --- a/front/components/V2/TabNavigationList.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { View, StyleProp, ViewStyle } from 'react-native'; - -type TabNavigationListProps = { - children: React.ReactNode; - style?: StyleProp; -}; - -const TabNavigationList = (props: TabNavigationListProps) => { - return ( - - {props.children} - - ); -}; - -export default TabNavigationList; diff --git a/front/components/V2/TabNavigationPhone.tsx b/front/components/V2/TabNavigationPhone.tsx deleted file mode 100644 index 852ad34..0000000 --- a/front/components/V2/TabNavigationPhone.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { View } from 'react-native'; -import { Center, ScrollView } from 'native-base'; -import TabNavigationButton from './TabNavigationButton'; -import { NaviTab } from './TabNavigation'; - -type TabNavigationPhoneProps = { - tabs: NaviTab[]; - activeTabID: string; - setActiveTabID: (id: string) => void; - children?: React.ReactNode; -}; - -const TabNavigationPhone = (props: TabNavigationPhoneProps) => { - return ( - - -
- - {props.tabs.map((tab) => ( - - - - ))} - -
-
- - {props.children} - -
- ); -}; - -export default TabNavigationPhone; diff --git a/front/components/leaderboard/BoardRow.tsx b/front/components/leaderboard/BoardRow.tsx new file mode 100644 index 0000000..68bb743 --- /dev/null +++ b/front/components/leaderboard/BoardRow.tsx @@ -0,0 +1,102 @@ +import { View } from 'react-native'; +import { Avatar, Text, useTheme } from 'native-base'; +import { getInitials } from '../UserAvatar'; + +type BoardRowProps = { + userAvatarUrl: string; + userPseudo: string; + userLvl: number; + index: number; +}; + +export const BoardRow = ({ userAvatarUrl, userPseudo, userLvl, index }: BoardRowProps) => { + const { colors } = useTheme(); + return ( + + + + {getInitials(userPseudo)} + + + + + {userPseudo} + + + {userLvl} + + + + {index} + + + + ); +}; diff --git a/front/components/leaderboard/PodiumCard.tsx b/front/components/leaderboard/PodiumCard.tsx new file mode 100644 index 0000000..485bdf6 --- /dev/null +++ b/front/components/leaderboard/PodiumCard.tsx @@ -0,0 +1,98 @@ +import { Text, Avatar, useTheme } from 'native-base'; +import { MedalStar } from 'iconsax-react-native'; +import { View } from 'react-native'; +import { getInitials } from '../UserAvatar'; + +type PodiumCardProps = { + offset: number; + medalColor: string; + userAvatarUrl?: string; + // pseudo and lvl are optional because only when + // we don't have the data for the 3rd place + userPseudo?: string; + userLvl?: number; +}; + +export const PodiumCard = ({ + offset, + medalColor, + userAvatarUrl, + userPseudo, + userLvl, +}: PodiumCardProps) => { + const { colors } = useTheme(); + return ( + + + + {userPseudo ? getInitials(userPseudo) : '---'} + + + + + + + + {userPseudo ?? '---'} + + + + {userLvl ?? '-'} + + + + + + ); +}; diff --git a/front/i18n/Translations.ts b/front/i18n/Translations.ts index 85c8cc6..f559ddb 100644 --- a/front/i18n/Translations.ts +++ b/front/i18n/Translations.ts @@ -32,6 +32,7 @@ export const en = { goNextStep: 'Step Up!', mySkillsToImprove: 'My Competencies to work on', recentlyPlayed: 'Recently played', + leaderboardTitle: 'Leaderboard', songsToGetBetter: 'Recommendations', lastSearched: 'Last searched', @@ -300,6 +301,11 @@ export const en = { selectPlayMode: 'Select a play mode', selectPlayModeExplaination: "'Practice' only considers the notes you play, while 'Play' mode also takes rhythm into account.", + + //leaderboard things + leaderBoardHeading: 'These are the best players', + leaderBoardHeadingFull: + 'The players having the best scores, thanks to their exceptional accuracy, are highlighted here.', }; export const fr: typeof en = { @@ -323,6 +329,7 @@ export const fr: typeof en = { lastScore: 'Dernier', bestStreak: 'Meilleure série', precision: 'Précision', + leaderboardTitle: "Tableau d'honneur", langBtn: 'Langage', backBtn: 'Retour', @@ -605,6 +612,11 @@ export const fr: typeof en = { selectPlayMode: 'Sélectionnez un mode de jeu', selectPlayModeExplaination: "Le mode 'S'entrainer' ne compte que les notes, tandis que le mode 'Jouer' prend en compte à la fois les notes et le rythme.", + + //leaderboard things + leaderBoardHeading: 'Voici les meilleurs joueurs', + leaderBoardHeadingFull: + 'Les joueurs présentant les meilleurs scores, grâce à leur précision exceptionnelle, sont mis en lumière ici.', }; export const sp: typeof en = { @@ -646,6 +658,7 @@ export const sp: typeof en = { mySkillsToImprove: 'Mis habilidades para mejorar', recentlyPlayed: 'Recientemente jugado', lastSearched: 'Ultimas búsquedas', + leaderboardTitle: 'tabla de clasificación', welcome: 'Benvenido a Chromacase', langBtn: 'Langua', @@ -916,4 +929,9 @@ export const sp: typeof en = { selectPlayMode: 'Selecciona un modo de juego', selectPlayModeExplaination: "El modo 'práctica' solo cuenta notas, mientras que el modo 'reproducir' tiene en cuenta tanto las notas como el ritmo.", + + //leaderboard things + leaderBoardHeading: 'Estos son los mejores jugadores.', + leaderBoardHeadingFull: + 'Aquí se destacan los jugadores que tienen las mejores puntuaciones, gracias a su precisión excepcional.', }; diff --git a/front/models/User.ts b/front/models/User.ts index 6a66759..929a265 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -18,6 +18,7 @@ export const UserValidator = yup googleID: yup.string().required().nullable(), isGuest: yup.boolean().required(), partyPlayed: yup.number().required(), + totalScore: yup.number().required(), }) .concat(ModelValidator); @@ -30,6 +31,7 @@ export const UserHandler: ResponseHandler, U premium: false, data: { gamesPlayed: value.partyPlayed as number, + totalScore: value.totalScore as number, xp: 0, createdAt: new Date('2023-04-09T00:00:00.000Z'), avatar: `${API.baseUrl}/users/${value.id}/picture`, @@ -51,6 +53,7 @@ interface User extends Model { interface UserData { gamesPlayed: number; xp: number; + totalScore: number; avatar: string; createdAt: Date; } diff --git a/front/views/HomeView.tsx b/front/views/HomeView.tsx index e575805..3c5b1b5 100644 --- a/front/views/HomeView.tsx +++ b/front/views/HomeView.tsx @@ -83,6 +83,12 @@ const HomeView = (props: RouteProps<{}>) => { size="sm" onPress={() => navigation.navigate('Settings', {})} /> + navigation.navigate('Leaderboard', {})} + /> >) => { + const navigation = useNavigation(); + const scoresQuery = useQuery(API.getTopTwentyPlayers()); + const screenSize = useBreakpointValue({ base: 'small', md: 'big' }); + const isPhone = screenSize === 'small'; + + if (scoresQuery.isError) { + navigation.navigate('Error'); + return <>; + } + if (!scoresQuery.data) { + return ( + + + + ); + } + + const podiumUserData = [ + scoresQuery.data.at(0), + scoresQuery.data.at(1), + scoresQuery.data.at(2), + ] as const; + + return ( + + + + + {translate('leaderBoardHeading')} + + + {translate('leaderBoardHeadingFull')} + + + {!isPhone ? ( + + + + + + ) : ( + + + + + + + + )} + + {scoresQuery.data.slice(3, 20).map((comp, index) => ( + + ))} + + + + + + ); +}; + +export default Leaderboardiew; diff --git a/front/views/PlayView.tsx b/front/views/PlayView.tsx index cd0657c..3dba567 100644 --- a/front/views/PlayView.tsx +++ b/front/views/PlayView.tsx @@ -139,6 +139,7 @@ const PlayView = ({ songId, route }: RouteProps) => { type: 'end', }) ); + API.updateUserTotalScore(score); }; const onMIDISuccess = (access: MIDIAccess) => {