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
isGuest Boolean @default(false)
partyPlayed Int @default(0)
totalScore Int @default(0)
LessonHistory LessonHistory[]
SongHistory SongHistory[]
searchHistory SearchHistory[]

View File

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

View File

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

View File

@@ -11,4 +11,6 @@ export class User {
isGuest: boolean;
@ApiProperty()
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 },
});
}
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 {
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 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 },

View File

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

View File

@@ -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])

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!',
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.',
};

View File

@@ -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<yup.InferType<typeof UserValidator>, 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;
}

View File

@@ -83,6 +83,12 @@ const HomeView = (props: RouteProps<{}>) => {
size="sm"
onPress={() => navigation.navigate('Settings', {})}
/>
<TextButton
translate={{ translationKey: 'leaderboardTitle' }}
colorScheme="primary"
size="sm"
onPress={() => navigation.navigate('Leaderboard', {})}
/>
<TextButton
label={'V2'}
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',
})
);
API.updateUserTotalScore(score);
};
const onMIDISuccess = (access: MIDIAccess) => {