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
|
||||
isGuest Boolean @default(false)
|
||||
partyPlayed Int @default(0)
|
||||
totalScore Int @default(0)
|
||||
LessonHistory LessonHistory[]
|
||||
SongHistory SongHistory[]
|
||||
searchHistory SearchHistory[]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ export class User {
|
||||
isGuest: boolean;
|
||||
@ApiProperty()
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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!',
|
||||
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.',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
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',
|
||||
})
|
||||
);
|
||||
API.updateUserTotalScore(score);
|
||||
};
|
||||
|
||||
const onMIDISuccess = (access: MIDIAccess) => {
|
||||
|
||||
Reference in New Issue
Block a user