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:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user