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
This commit is contained in:
Amaury
2023-12-04 23:37:06 +01:00
committed by GitHub
parent dc0c7fa4e7
commit 4ac6369deb
25 changed files with 516 additions and 373 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totalScore" INTEGER NOT NULL DEFAULT 0;

View File

@@ -24,6 +24,7 @@ model User {
googleID String? @unique googleID String? @unique
isGuest Boolean @default(false) isGuest Boolean @default(false)
partyPlayed Int @default(0) partyPlayed Int @default(0)
totalScore Int @default(0)
LessonHistory LessonHistory[] LessonHistory LessonHistory[]
SongHistory SongHistory[] SongHistory SongHistory[]
searchHistory SearchHistory[] searchHistory SearchHistory[]

View File

@@ -15,6 +15,7 @@ import { AlbumModule } from "./album/album.module";
import { SearchModule } from "./search/search.module"; import { SearchModule } from "./search/search.module";
import { HistoryModule } from "./history/history.module"; import { HistoryModule } from "./history/history.module";
import { MailerModule } from "@nestjs-modules/mailer"; import { MailerModule } from "@nestjs-modules/mailer";
import { ScoresModule } from "./scores/scores.module";
@Module({ @Module({
imports: [ imports: [
@@ -29,6 +30,7 @@ import { MailerModule } from "@nestjs-modules/mailer";
SearchModule, SearchModule,
SettingsModule, SettingsModule,
HistoryModule, HistoryModule,
ScoresModule,
MailerModule.forRoot({ MailerModule.forRoot({
transport: process.env.SMTP_TRANSPORT, transport: process.env.SMTP_TRANSPORT,
defaults: { defaults: {

View File

@@ -311,4 +311,19 @@ export class AuthController {
mapInclude(include, req, SongController.includableFields), 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,
);
}
} }

View File

@@ -11,4 +11,6 @@ export class User {
isGuest: boolean; isGuest: boolean;
@ApiProperty() @ApiProperty()
partyPlayed: number; partyPlayed: number;
@ApiProperty()
totalScore: number;
} }

View File

@@ -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<User[]> {
return this.scoresService.topTwenty();
}
}

View File

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

View File

@@ -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<User[]> {
return this.prisma.user.findMany({
orderBy: {
totalScore: 'desc',
},
take: 20,
});
}
}

View File

@@ -117,4 +117,18 @@ export class UsersService {
where: { userId: userId, songId: songId }, where: { userId: userId, songId: songId },
}); });
} }
async addScore(
where: number,
score: number,
) {
return this.prisma.user.update({
where: { id: where },
data: {
partyPlayed: {
increment: score,
},
},
});
}
} }

View File

@@ -733,4 +733,25 @@ export default class API {
public static getPartitionSvgUrl(songId: number): string { public static getPartitionSvgUrl(songId: number): string {
return `${API.baseUrl}/song/${songId}/assets/partition`; return `${API.baseUrl}/song/${songId}/assets/partition`;
} }
public static async updateUserTotalScore(score: number): Promise<void> {
await API.fetch({
route: `/auth/me/score/${score}`,
method: 'PATCH',
});
}
public static getTopTwentyPlayers(): Query<User[]> {
return {
key: ['score'],
exec: () =>
API.fetch(
{
route: '/scores/top/20',
method: 'GET',
},
{ handler: ListHandler(UserHandler) }
),
};
}
} }

View File

@@ -33,6 +33,7 @@ import PasswordResetView from './views/PasswordResetView';
import ForgotPasswordView from './views/ForgotPasswordView'; import ForgotPasswordView from './views/ForgotPasswordView';
import DiscoveryView from './views/V2/DiscoveryView'; import DiscoveryView from './views/V2/DiscoveryView';
import MusicView from './views/MusicView'; import MusicView from './views/MusicView';
import Leaderboardiew from './views/LeaderboardView';
// Util function to hide route props in URL // Util function to hide route props in URL
const removeMe = () => ''; const removeMe = () => '';
@@ -87,6 +88,11 @@ const protectedRoutes = () =>
options: { headerShown: false }, options: { headerShown: false },
link: '/search/:query?', link: '/search/:query?',
}, },
Leaderboard: {
component: Leaderboardiew,
options: { title: translate('leaderboardTitle'), headerShown: false },
link: '/leaderboard',
},
Error: { Error: {
component: ErrorView, component: ErrorView,
options: { title: translate('error'), headerLeft: null }, options: { title: translate('error'), headerLeft: null },

View File

@@ -13,7 +13,7 @@ const menu = [
{ type: 'main', title: 'menuProfile', icon: User, link: 'User' }, { type: 'main', title: 'menuProfile', icon: User, link: 'User' },
{ type: 'main', title: 'menuMusic', icon: Music, link: 'Music' }, { type: 'main', title: 'menuMusic', icon: Music, link: 'Music' },
{ type: 'main', title: 'menuSearch', icon: SearchNormal1, link: 'Search' }, { 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' }, { type: 'sub', title: 'menuSettings', icon: Setting2, link: 'Settings' },
] as const; ] as const;

View File

@@ -3,7 +3,7 @@ import API from '../API';
import { useQuery } from '../Queries'; import { useQuery } from '../Queries';
import { useMemo } from 'react'; import { useMemo } from 'react';
const getInitials = (name: string) => { export const getInitials = (name: string) => {
return name return name
.split(' ') .split(' ')
.map((n) => n[0]) .map((n) => n[0])

View File

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

View File

@@ -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 (
<Pressable
onPress={props.onPress}
onLongPress={props.onLongPress}
style={{
width: '100%',
}}
>
{({ 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 (
<View
style={{
display: 'flex',
flexDirection: 'row',
alignSelf: 'stretch',
alignItems: 'center',
justifyContent: 'flex-start',
padding: 10,
borderRadius: 8,
flexGrow: 0,
// @ts-expect-error boxShadow isn't yet supported by react native
boxShadow: boxShadow,
backdropFilter: 'blur(2px)',
backgroundColor: (() => {
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 && (
<View
style={{
marginRight: props.isCollapsed ? undefined : 10,
}}
>
{props.icon}
</View>
)}
{!props.isCollapsed && (
<Text numberOfLines={1} selectable={false}>
{props.label}
</Text>
)}
</View>
);
}}
</Pressable>
);
};
TabNavigationButton.defaultProps = {
icon: undefined,
onPress: () => {},
onLongPress: () => {},
isActive: false,
isCollapsed: false,
};
export default TabNavigationButton;

View File

@@ -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 (
<View
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
height: '100%',
}}
>
<View>
<Center>
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flexShrink: 0,
padding: 10,
}}
>
<Image
source={{ uri: icon?.at(0)?.uri }}
style={{
aspectRatio: 1,
width: 40,
height: 'auto',
marginRight: 10,
}}
/>
<Text fontSize={'2xl'} selectable={false}>
Chromacase
</Text>
</View>
</Center>
<View
style={{
display: 'flex',
width: 300,
height: 'auto',
padding: 32,
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexGrow: 1,
}}
>
<TabNavigationList
style={{
flexShrink: 0,
gap: 20,
}}
>
{buttons.map((button, index) => (
<TabNavigationButton
key={'tab-navigation-button-' + index}
icon={button.icon}
label={button.label}
isActive={button.id == props.activeTabID}
onPress={button.onPress}
onLongPress={button.onLongPress}
isCollapsed={props.isCollapsed}
/>
))}
</TabNavigationList>
<TabNavigationList>
<Divider />
<TabNavigationList>
<Text
bold
style={{
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 20,
}}
>
Recently played
</Text>
{history.data?.length === 0 && (
<Text
style={{
paddingHorizontal: 16,
paddingVertical: 10,
}}
>
No songs played yet
</Text>
)}
{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) => (
<View
key={'tab-navigation-other-' + index}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
}}
>
<Text numberOfLines={1}>{histoItem.name}</Text>
</View>
))}
</TabNavigationList>
<Divider />
<TabNavigationList
style={{
gap: 20,
}}
>
{([props.tabs.find((t) => t.id === 'settings')] as NaviTab[]).map(
(button, index) => (
<TabNavigationButton
key={'tab-navigation-setting-button-' + index}
icon={button.icon}
label={button.label}
isActive={button.id == props.activeTabID}
onPress={button.onPress}
onLongPress={button.onLongPress}
isCollapsed={props.isCollapsed}
/>
)
)}
</TabNavigationList>
</TabNavigationList>
</View>
</View>
<ScrollView
// @ts-expect-error Raw CSS
style={{
height: '100%',
width: 'calc(100% - 300px)',
}}
>
{props.children}
</ScrollView>
</View>
);
};
export default TabNavigationDesktop;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { View, StyleProp, ViewStyle } from 'react-native';
type TabNavigationListProps = {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
const TabNavigationList = (props: TabNavigationListProps) => {
return (
<View
style={[
{
display: 'flex',
alignItems: 'flex-start',
alignSelf: 'stretch',
flexDirection: 'column',
gap: 8,
},
props.style,
]}
>
{props.children}
</View>
);
};
export default TabNavigationList;

View File

@@ -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 (
<View
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
<View
style={{
padding: 16,
height: 90,
width: '100%',
}}
>
<Center>
<View
style={{
display: 'flex',
padding: 8,
justifyContent: 'space-evenly',
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch',
borderRadius: 8,
backgroundColor: 'rgba(16, 16, 20, 0.5)',
}}
>
{props.tabs.map((tab) => (
<View key={'navigation-button-phone-' + tab.label}>
<TabNavigationButton
icon={tab.icon}
label={tab.label}
onPress={tab.onPress}
onLongPress={tab.onLongPress}
isActive={tab.id === props.activeTabID}
isCollapsed={tab.id != props.activeTabID}
/>
</View>
))}
</View>
</Center>
</View>
<ScrollView
// @ts-expect-error Raw CSS
style={{
width: '100%',
height: 'calc(100% - 90px)',
}}
>
{props.children}
</ScrollView>
</View>
);
};
export default TabNavigationPhone;

View File

@@ -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 (
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch',
borderRadius: 8,
overflow: 'hidden',
backgroundColor: colors.coolGray[500],
shadowColor: 'rgba(0, 0, 0, 0.25)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 4,
height: 50,
}}
>
<View
style={{
height: '100%',
aspectRatio: 1,
}}
>
<Avatar
bg={colors.coolGray[900]}
source={{
uri: userAvatarUrl,
}}
style={{
flex: 1,
borderRadius: 0,
}}
_image={{
borderRadius: 0,
}}
>
{getInitials(userPseudo)}
</Avatar>
</View>
<Text
style={{
fontSize: 16,
fontStyle: 'normal',
flex: 1,
marginHorizontal: 10,
fontWeight: '500',
maxWidth: '100%',
}}
isTruncated
numberOfLines={2}
>
{userPseudo}
</Text>
<Text
style={{
flexShrink: 0,
flexGrow: 0,
fontSize: 16,
fontStyle: 'normal',
fontWeight: '500',
marginHorizontal: 10,
}}
>
{userLvl}
</Text>
<View
style={{
backgroundColor: 'rgba(255, 255, 255, 0.50)',
aspectRatio: 1,
height: '100%',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 16,
fontStyle: 'normal',
fontWeight: '500',
textAlign: 'center',
}}
>
{index}
</Text>
</View>
</View>
);
};

View File

@@ -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 (
<View
style={{
display: 'flex',
paddingTop: offset,
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
width: 140,
gap: 5,
}}
>
<View
style={{
width: 140,
height: 140,
}}
>
<Avatar
size="2xl"
bg={colors.coolGray[900]}
source={{
uri: userAvatarUrl,
}}
style={{
width: '100%',
height: '100%',
borderRadius: 12,
overflow: 'hidden',
}}
_image={{
borderRadius: 0,
}}
>
{userPseudo ? getInitials(userPseudo) : '---'}
<Avatar.Badge bg="coolGray.900">
<MedalStar size="24" variant="Bold" color={medalColor} />
</Avatar.Badge>
</Avatar>
</View>
<View>
<Text
style={{
fontSize: 20,
fontWeight: '500',
maxWidth: '100%',
}}
numberOfLines={2}
isTruncated
>
{userPseudo ?? '---'}
</Text>
<View
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 5,
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '500',
}}
>
{userLvl ?? '-'}
</Text>
<MedalStar size="24" variant="Bold" color={medalColor} />
</View>
</View>
</View>
);
};

View File

@@ -32,6 +32,7 @@ export const en = {
goNextStep: 'Step Up!', goNextStep: 'Step Up!',
mySkillsToImprove: 'My Competencies to work on', mySkillsToImprove: 'My Competencies to work on',
recentlyPlayed: 'Recently played', recentlyPlayed: 'Recently played',
leaderboardTitle: 'Leaderboard',
songsToGetBetter: 'Recommendations', songsToGetBetter: 'Recommendations',
lastSearched: 'Last searched', lastSearched: 'Last searched',
@@ -300,6 +301,11 @@ export const en = {
selectPlayMode: 'Select a play mode', selectPlayMode: 'Select a play mode',
selectPlayModeExplaination: selectPlayModeExplaination:
"'Practice' only considers the notes you play, while 'Play' mode also takes rhythm into account.", "'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 = { export const fr: typeof en = {
@@ -323,6 +329,7 @@ export const fr: typeof en = {
lastScore: 'Dernier', lastScore: 'Dernier',
bestStreak: 'Meilleure série', bestStreak: 'Meilleure série',
precision: 'Précision', precision: 'Précision',
leaderboardTitle: "Tableau d'honneur",
langBtn: 'Langage', langBtn: 'Langage',
backBtn: 'Retour', backBtn: 'Retour',
@@ -605,6 +612,11 @@ export const fr: typeof en = {
selectPlayMode: 'Sélectionnez un mode de jeu', selectPlayMode: 'Sélectionnez un mode de jeu',
selectPlayModeExplaination: 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.", "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 = { export const sp: typeof en = {
@@ -646,6 +658,7 @@ export const sp: typeof en = {
mySkillsToImprove: 'Mis habilidades para mejorar', mySkillsToImprove: 'Mis habilidades para mejorar',
recentlyPlayed: 'Recientemente jugado', recentlyPlayed: 'Recientemente jugado',
lastSearched: 'Ultimas búsquedas', lastSearched: 'Ultimas búsquedas',
leaderboardTitle: 'tabla de clasificación',
welcome: 'Benvenido a Chromacase', welcome: 'Benvenido a Chromacase',
langBtn: 'Langua', langBtn: 'Langua',
@@ -916,4 +929,9 @@ export const sp: typeof en = {
selectPlayMode: 'Selecciona un modo de juego', selectPlayMode: 'Selecciona un modo de juego',
selectPlayModeExplaination: selectPlayModeExplaination:
"El modo 'práctica' solo cuenta notas, mientras que el modo 'reproducir' tiene en cuenta tanto las notas como el ritmo.", "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.',
}; };

View File

@@ -18,6 +18,7 @@ export const UserValidator = yup
googleID: yup.string().required().nullable(), googleID: yup.string().required().nullable(),
isGuest: yup.boolean().required(), isGuest: yup.boolean().required(),
partyPlayed: yup.number().required(), partyPlayed: yup.number().required(),
totalScore: yup.number().required(),
}) })
.concat(ModelValidator); .concat(ModelValidator);
@@ -30,6 +31,7 @@ export const UserHandler: ResponseHandler<yup.InferType<typeof UserValidator>, U
premium: false, premium: false,
data: { data: {
gamesPlayed: value.partyPlayed as number, gamesPlayed: value.partyPlayed as number,
totalScore: value.totalScore as number,
xp: 0, xp: 0,
createdAt: new Date('2023-04-09T00:00:00.000Z'), createdAt: new Date('2023-04-09T00:00:00.000Z'),
avatar: `${API.baseUrl}/users/${value.id}/picture`, avatar: `${API.baseUrl}/users/${value.id}/picture`,
@@ -51,6 +53,7 @@ interface User extends Model {
interface UserData { interface UserData {
gamesPlayed: number; gamesPlayed: number;
xp: number; xp: number;
totalScore: number;
avatar: string; avatar: string;
createdAt: Date; createdAt: Date;
} }

View File

@@ -83,6 +83,12 @@ const HomeView = (props: RouteProps<{}>) => {
size="sm" size="sm"
onPress={() => navigation.navigate('Settings', {})} onPress={() => navigation.navigate('Settings', {})}
/> />
<TextButton
translate={{ translationKey: 'leaderboardTitle' }}
colorScheme="primary"
size="sm"
onPress={() => navigation.navigate('Leaderboard', {})}
/>
<TextButton <TextButton
label={'V2'} label={'V2'}
colorScheme="gray" colorScheme="gray"

View File

@@ -0,0 +1,174 @@
import { useBreakpointValue, ScrollView, Text } from 'native-base';
import { SafeAreaView, View } from 'react-native';
import { useQuery } from '../Queries';
import API from '../API';
import { LoadingView } from '../components/Loading';
import { useNavigation, RouteProps } from '../Navigation';
import ScaffoldCC from '../components/UI/ScaffoldCC';
import { translate } from '../i18n/i18n';
import { PodiumCard } from '../components/leaderboard/PodiumCard';
import { BoardRow } from '../components/leaderboard/BoardRow';
const Leaderboardiew = (props: RouteProps<Record<string, never>>) => {
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 (
<ScaffoldCC routeName={props.route.name}>
<LoadingView />
</ScaffoldCC>
);
}
const podiumUserData = [
scoresQuery.data.at(0),
scoresQuery.data.at(1),
scoresQuery.data.at(2),
] as const;
return (
<ScaffoldCC routeName={props.route.name}>
<SafeAreaView>
<ScrollView>
<Text
style={{
fontSize: 20,
fontWeight: '500',
marginBottom: 16,
}}
>
{translate('leaderBoardHeading')}
</Text>
<Text
style={{
fontSize: 14,
fontWeight: '500',
}}
>
{translate('leaderBoardHeadingFull')}
</Text>
<View
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
paddingTop: 20,
flex: 1,
alignSelf: 'stretch',
}}
>
{!isPhone ? (
<View /** podium view */
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'stretch',
gap: 32,
paddingBottom: 10,
marginBottom: 20,
}}
>
<PodiumCard
medalColor="#AE84FB"
offset={120}
userAvatarUrl={podiumUserData[2]?.data.avatar}
userPseudo={podiumUserData[2]?.name}
userLvl={podiumUserData[2]?.data.totalScore}
/>
<PodiumCard
medalColor="#EAD93C"
offset={0}
userAvatarUrl={podiumUserData[0]?.data.avatar}
userPseudo={podiumUserData[0]?.name}
userLvl={podiumUserData[0]?.data.totalScore}
/>
<PodiumCard
medalColor="#5F74F7"
offset={60}
userAvatarUrl={podiumUserData[1]?.data.avatar}
userPseudo={podiumUserData[1]?.name}
userLvl={podiumUserData[1]?.data.totalScore}
/>
</View>
) : (
<View
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'stretch',
marginBottom: 20,
marginHorizontal: 10,
}}
>
<PodiumCard
medalColor="#EAD93C"
offset={0}
userAvatarUrl={podiumUserData[0]?.data.avatar}
userPseudo={podiumUserData[0]?.name}
userLvl={podiumUserData[0]?.data.totalScore}
/>
<View
style={{
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'center',
alignItems: 'center',
gap: 64,
}}
>
<PodiumCard
medalColor="#5F74F7"
offset={0}
userAvatarUrl={podiumUserData[1]?.data.avatar}
userPseudo={podiumUserData[1]?.name}
userLvl={podiumUserData[1]?.data.totalScore}
/>
<PodiumCard
medalColor="#AE84FB"
offset={60}
userAvatarUrl={podiumUserData[2]?.data.avatar}
userPseudo={podiumUserData[2]?.name}
userLvl={podiumUserData[2]?.data.totalScore}
/>
</View>
</View>
)}
<View
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
alignSelf: 'stretch',
gap: 10,
paddingRight: 10,
}}
>
{scoresQuery.data.slice(3, 20).map((comp, index) => (
<BoardRow
key={comp.name + index}
index={index + 4}
userAvatarUrl={comp.data.avatar}
userLvl={comp.data.totalScore}
userPseudo={comp.name}
/>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
</ScaffoldCC>
);
};
export default Leaderboardiew;

View File

@@ -139,6 +139,7 @@ const PlayView = ({ songId, route }: RouteProps<PlayViewProps>) => {
type: 'end', type: 'end',
}) })
); );
API.updateUserTotalScore(score);
}; };
const onMIDISuccess = (access: MIDIAccess) => { const onMIDISuccess = (access: MIDIAccess) => {