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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "totalScore" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ export class User {
|
|||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
partyPlayed: number;
|
partyPlayed: number;
|
||||||
|
@ApiProperty()
|
||||||
|
totalScore: number;
|
||||||
}
|
}
|
||||||
|
|||||||
19
back/src/scores/scores.controller.ts
Normal file
19
back/src/scores/scores.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
back/src/scores/scores.module.ts
Normal file
11
back/src/scores/scores.module.ts
Normal 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 {}
|
||||||
19
back/src/scores/scores.service.ts
Normal file
19
back/src/scores/scores.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
front/API.ts
21
front/API.ts
@@ -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) }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
102
front/components/leaderboard/BoardRow.tsx
Normal file
102
front/components/leaderboard/BoardRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
98
front/components/leaderboard/PodiumCard.tsx
Normal file
98
front/components/leaderboard/PodiumCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
174
front/views/LeaderboardView.tsx
Normal file
174
front/views/LeaderboardView.tsx
Normal 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;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user